Client -> Server: Raw event stream (keyboard/mouse/touch/resize events)
Server -> Client: Canvas draw batch stream
The server provides a small javascript shim that bootstraps a websocket & subscribes to all required event sources. It also subscribes to the server issued batch events and has logic to dispatch draw commands to the canvas element.On the server, I use LMAX Disruptor to aggregate the client events and process them in micro batches (the size of which are determined dynamically based on backpressure). This results in an incredibly low-latency/low-jitter UI. Client events are passed around as readonly structs, so very little allocation or GC is involved throughout (.NET5/C# codebase). Processing is concluded with async/parallel dispatch of the appropriate client-side draw batches as they are ready to be issued. Not all client events result in a draw, and not all client events result in draws on the same client. There are some synchronous concerns between clients in my application, so having a single fast thread allows for lock-free processing of all events at the same time.
The only caveat I have encountered is the latency constraint. Going over ~100ms makes this sort of interface feel like crap. For LAN/localhost, this approach is effectively instantaneous.