Back to Blog

Streaming H.264 Remote-Desktop Video over a WebSocket and Decoding It with WebCodecs

Christopher 6 min read
remote-desktopwebcodecsh264linux

This post documents the pipeline ET Ducky uses to deliver H.264 remote-desktop video to a browser without WebRTC. A capture helper on the Linux host encodes H.264, the agent relays Annex-B NALUs over a WebSocket with a small binary framing protocol, and the dashboard decodes with the WebCodecs VideoDecoder and paints VideoFrame objects to a canvas. It covers the wire protocol, the SPS/PPS handling that WebCodecs configuration requires, keyframe recovery, the JPEG fallback, and the bandwidth failure mode that led to the move to H.264.

Why the pipeline does not use WebRTC

WebRTC is the common choice for real-time video in a browser. It does not fit this case. The remote host is an unattended endpoint behind arbitrary NAT, and sessions relay through the same authenticated WebSocket infrastructure the agent already maintains, with no STUN/TURN deployment, no SDP negotiation, and no ICE state machine. WebRTC would add congestion-controlled UDP transport, which matters less for desktop content at 12 to 30 fps than it does for camera video. It would also add a second transport stack and a second security surface. WebCodecs removed the need for it. The browser exposes the hardware H.264 decoder directly, so a plain WebSocket carrying NALUs plus VideoDecoder gives hardware-accelerated playback over the transport the agent already uses.

The bandwidth failure that led to H.264

The first-generation pipeline sent JPEG frames. At quality 75, 1718×820, and 20 fps, the stream ran 5 to 8 MB/s, which was enough to saturate a typical office uplink. The failure did not present as visible lag. Sessions ended at exactly 30 seconds. The chain was: the socket's send buffer filled, ws.SendAsync stalled, the stalled client could not dispatch its 30-second keep-alive ping, the server saw an idle channel and closed it, and the helper tore down. The 30-second signature in the journal pointed at the keep-alive timer that the stalled send had starved.

Two mitigations shipped. JPEG quality dropped to 50 and the frame cap to 12 fps, which roughly halved bandwidth with acceptable quality for desktop text. H.264 then became the default. Encoded desktop content with inter-frame deltas runs about 250 to 500 KB/s at 1080p, 30 fps, and a 2 Mbps target bitrate, an order of magnitude below the JPEG stream.

Encoder side

The Linux capture helper encodes with whichever H.264 element is available, preferring hardware (vah264enc, VAAPI) and falling back to software (x264enc). The agent launches it with --codec h264 --max-fps 30 --bitrate 2000 --keyframe-interval 120. Passing --codec h264 explicitly turns a missing encoder into a visible failure. If neither encoder exists on the host, the helper exits with an error instead of degrading to JPEG. On production hosts a codec change is an operator decision, and a silent fallback would surface later in a bandwidth graph.

Wire protocol

The helper-to-agent framing distinguishes three payload types: JPEG full frames (type 1), H.264 NALUs (type 2, Annex-B start-code prefixed, with a keyframe bit in a flags byte), and an H.264 config payload (type 4) carrying SPS+PPS, emitted once at session start. The agent relays to the browser using the dashboard's existing frame protocol, extended with two fields:

type=6 codec announce:
  byte 0     = 6
  byte 1     = codec        (1 = JPEG, 2 = H.264)
  bytes 2-3  = width  (LE u16)
  bytes 4-5  = height (LE u16)
  bytes 6-9  = reserved
  bytes 10+  = SPS+PPS      (Annex-B; H.264 only)

type=1 frame:
  byte 0     = 1
  bytes 1-2  = width
  bytes 3-4  = height
  bytes 5-8  = x, y
  byte 9     = flags        (bit 0 = keyframe)
  byte 10    = codec        (1 = JPEG, 2 = H.264)
  bytes 11-14 = sequence
  bytes 15+  = NALU or JPEG bytes

Ordering is a hard requirement: the codec announce must reach the browser before the first NALU frame, because the decoder cannot be configured without the parameter sets. The agent tracks whether the announce has been sent and holds frames until it has. The JPEG path never produces a config payload, so the agent synthesizes a codec announce lazily on the first JPEG frame. The browser then routes frames to the legacy image decoder instead of waiting for SPS/PPS that will never arrive.

WebCodecs specifics on the decoder side

On the dashboard, the H.264 path is a sidecar module rather than an edit to the existing renderer. It wraps WebSocket at the prototype level, inspects binary messages before the JPEG renderer's onmessage sees them, decodes type-2 frames itself, and calls stopImmediatePropagation() so the JPEG path never attempts to decode an H.264 NALU. The reason is operational. The renderer ships minified, and patching a documented interception point is more maintainable than pattern-matching changes into a minified bundle. The same technique is used for the multi-display fix described at the end of this post.

The WebCodecs details that cost time:

Multi-display switching and the renderer buffer size

A related renderer bug is recorded here because its symptom did not point at the cause. Switching the captured display appeared to do nothing. The agent did re-capture from the new monitor and frames arrived, but the screen did not change. The renderer's offscreen buffer had been sized once, to the primary display, and never resized. New frames from a different-geometry display were drawn into a wrong-sized buffer, clipped and composited over the stale image. The fix was for the agent to broadcast fresh display info including the active display index after a switch, and for the frontend to size the renderer to the active display's dimensions instead of the primary's. In this pipeline, an unchanged screen after a display switch meant the frame data had changed and a buffer between the data and the pixels had not.

What this pipeline does not do

There is no audio, no congestion-controlled transport, and no bandwidth adaptation beyond the encoder's fixed target bitrate; a link that cannot sustain roughly 500 KB/s will fall behind rather than degrade quality. Cursor rendering, clipboard synchronization, and input injection are separate channels in the same session protocol and are unaffected by codec choice. Windows hosts use a different capture path (DXGI/GDI via a signed helper, covered in the secure-desktop post); the pipeline described here is the Linux path end to end.

ET Ducky

Documentation and pricing are available on this site.

View Pricing