Skip to content

Send webterm frames as deltas, paced against the client - #32

Merged
FireSquid6 merged 3 commits into
mainfrom
27-faster-webterm-protocol
Jul 27, 2026
Merged

Send webterm frames as deltas, paced against the client#32
FireSquid6 merged 3 commits into
mainfrom
27-faster-webterm-protocol

Conversation

@FireSquid6

Copy link
Copy Markdown
Owner

Closes #27.

The webterm protocol sent a complete grid snapshot every ~16 ms, uncompressed, without
ever checking whether the socket was draining. On a spotty link that is slow in two separate
ways: every frame is enormous, and frames pile up in the send buffer faster than they leave,
so what the user sees drifts further and further behind what the terminal is actually doing.

Measured before touching anything: a single keystroke on a 120×40 terminal cost 10 KB,
and 4 seconds of build output left the client 39 seconds behind on a 400 kbps link.

What changed

Deltas. grid gains a per-connection seq, and a new patch frame carries runs of
consecutive changed cells ([row, col, cells]) against the frame numbered seq - 1. Runs
rather than per-cell entries, because a scroll or a repainted status line changes whole spans
at once. A grid is still self-syncing and is what goes out first, after a resize, and on
request.

Pacing. The bridge now runs a FrameSequencer. At most 2 frames may be in flight
unacked before it stops producing, and the ship additionally blocks while its own socket is
backed up (>256 KiB). Clients ack every frame they apply. Crucially the diff baseline is
the last frame actually sent, never the last one computed — so skipping a frame is
lossless, the next patch just covers more. A terminal whose cells and cursor did not move
sends nothing at all.

Recovery. A client that sees a gap in seq sends resync and paints nothing until a
full grid arrives; exactly one request goes out per gap, so a congested link doesn't get a
burst of them. resync reopens the ack window as well as forcing a full frame — without
that, a client that lost sequence stops acking and wedges the stream permanently.

Compression. The ship, bridge, and fleet-client servers now accept permessage-deflate.
Bun's client WebSocket already offers it, so every hop in browser → fleet-client → bridge → ship compresses without touching the call sites.

Three commits, each leaving the repo green: the protocol and codec, then both clients
learning to apply patches, then the server actually sending them. That order is deliberate —
the clients can handle patches before any exist, so no commit is a broken intermediate state.

Measurements

Two benchmarks, run against this branch and against its merge-base with the working tree
stashed. Not committed — they live outside the repo and import it by path.

  • frame cost — drives a real bun-vt terminal with realistic PTY output and serializes
    every frame exactly as TerminalBridge does. "after" is the delta frame under
    permessage-deflate with context takeover, which is what a browser negotiates.
  • end to end — runs the real TerminalBridge against a real PTY and pipes its frames
    through a simulated bottleneck link (bytes ÷ bandwidth serialization delay, plus
    propagation delay, frames queueing behind each other). Keystrokes travel back over the same
    link, so echo is a true round trip.

What a frame costs

80×24

workload frames before (mean/frame) after (mean/frame) total on the wire
typing (one keystroke per frame) 39 4.4 KB 23 B 170.5 KB → 908 B (192×)
ls with colors 2 7.6 KB 329 B 15.2 KB → 658 B (24×)
scrolling log output 201 16.4 KB 154 B 3.23 MB → 30.3 KB (109×)
htop-style TUI tick 61 14.8 KB 35 B 899.8 KB → 2.1 KB (429×)
5000-line dump 13 12.5 KB 164 B 163.0 KB → 2.1 KB (78×)

120×40

workload frames before (mean/frame) after (mean/frame) total on the wire
typing (one keystroke per frame) 39 10.0 KB 25 B 391.2 KB → 965 B (415×)
ls with colors 2 13.3 KB 358 B 26.5 KB → 717 B (38×)
scrolling log output 201 29.9 KB 342 B 5.87 MB → 67.1 KB (90×)
htop-style TUI tick 61 28.4 KB 39 B 1.69 MB → 2.3 KB (752×)
5000-line dump 13 24.2 KB 249 B 314.3 KB → 3.2 KB (99×)

200×50

workload frames before (mean/frame) after (mean/frame) total on the wire
typing (one keystroke per frame) 39 20.2 KB 27 B 788.0 KB → 1.0 KB (763×)
ls with colors 2 23.4 KB 406 B 46.9 KB → 811 B (59×)
scrolling log output 201 44.6 KB 476 B 8.76 MB → 93.4 KB (96×)
htop-style TUI tick 61 43.6 KB 43 B 2.60 MB → 2.6 KB (1036×)
5000-line dump 13 38.1 KB 305 B 495.2 KB → 3.9 KB (128×)

What it feels like

Terminal 100×30. Link profiles: broadband 20 Mbps/25 ms one-way, 4g-spotty 1.5 Mbps/150 ms,
poor 400 kbps/400 ms. Cells read before → after → after with compression.

Sustained output — a ~4 s build log, 300 lines, 150 frames produced. catch-up is how
far behind the client's view is when the output stops.

link bytes lag p50 catch-up frames delivered
lan 2111 KB → 298 KB → 20 KB 2 ms → 1 ms → 1 ms 2 ms → 1 ms → 1 ms 150 → 150 → 151
broadband 2111 KB → 298 KB → 20 KB 30 ms → 25 ms → 25 ms 30 ms → 25 ms → 26 ms 150 → 150 → 149
4g-spotty 2111 KB → 81 KB → 9 KB 3.57 s → 161 ms → 151 ms 7.30 s → 160 ms → 152 ms 150 → 28 → 29
poor 2111 KB → 47 KB → 5 KB 19.26 s → 524 ms → 407 ms 39.24 s → 455 ms → 406 ms 150 → 11 → 13

39.2 s behind → 0.41 s. 7.3 s → 0.15 s. Before, the lag grew monotonically through the
run — unbounded queue buildup, not a constant offset.

The frame counts are the ack window working, not frames being lost: on the poor link the
client now receives 13 frames instead of 150, each a patch covering everything since the last
frame actually sent. The client is always looking at near-current state instead of working
through a backlog of stale ones.

Echo latency — 24 keystrokes into cat at a human cadence.

link p50 bytes
lan 21 ms → 21 ms → 21 ms 147 KB → 9 KB → 1 KB
broadband 72 ms → 69 ms → 71 ms 147 KB → 9 KB → 1 KB
4g-spotty 352 ms → 320 ms → 320 ms 147 KB → 9 KB → 1 KB
poor 944 ms → 820 ms → 821 ms 147 KB → 9 KB → 1 KB

Typing never queued — the user pauses between keystrokes — so its latency was always
dominated by propagation delay, which no protocol change recovers. What changed is the cost:
147 KB → 1 KB for the same 24 characters. The ~125 ms transmission share on the poor
profile is now ~1 ms, which is the 944 → 821 ms.

Notes for review

  • No version negotiation. Nothing in fleet-protocol negotiates versions today, and this
    follows that: ship, bridge, and client are assumed to deploy together. A new ship talking
    to an old browser would send it patches it cannot apply. An init capability flag would be
    ~30 lines if that assumption is wrong.
  • Binary framing (option 5 in the issue thread) now looks not worth doing. A compressed
    typing frame is 23–27 bytes; there is nothing left for a binary format to win.
  • Non-acking clients stall at 2 frames. A 5 s safety valve then forces a full snapshot, so
    such a client degrades to one frame per 5 s instead of freezing. Deliberate, commented.
  • One narrow gap: if congested() blocks a frame while no frames are unacked, nothing
    re-arms the frame timer until the next PTY byte, so a final frame could sit undelivered.
    It should be unreachable — 256 KiB buffered with an empty ack window is a contradiction
    when the window caps in-flight at 2 frames — but it is the one path without a guaranteed
    wake-up.
  • ws.raw.getBufferedAmount() is reachable at runtime but missing from the ServerWebSocket
    declaration Elysia bundles, so the ship reads it defensively and a test pins its existence.
    If that test ever fails, the congestion signal has silently become a constant false.

Docs updated in the same commits: packages/webterm.md, concepts/terminals.md (its "Frames
are full snapshots" section is rewritten), reference/ship-api.md.

bun run typecheck 0 errors, bun run test 746 pass / 0 fail.

🤖 Generated with Claude Code

Every webterm frame is a full grid snapshot today, which measures at 10 KB
per keystroke on a 120x40 terminal and leaves a client 39 s behind after 4 s
of build output on a 400 kbps link.

This lands the wire format and the pure codec for sending deltas instead:

- `grid` gains `seq`, a per-connection frame counter
- `patch` carries runs of consecutive changed cells against frame `seq - 1`
- `ack` and `resync` let a client pace the server and recover from a gap
- `diffGrid` / `applyPatch` implement the two ends, with runs rather than
  per-cell entries because a scroll changes whole spans at once

Nothing emits or consumes a patch yet — the bridge's client-message switch
ignores the new frames, so behavior is unchanged. Teaching the clients to
apply patches lands next, and only then does the server start sending them.
Both clients now drive a shared `GridStream`: it holds the current snapshot,
applies in-order patches to it, acks every frame it actually applied, and asks
for a full `grid` when it sees a sequence gap. Exactly one resync goes out per
gap — re-requesting on each following patch would pile a burst of requests
onto the congested link that caused the gap.

The renderers are untouched: both still receive complete `GridMsg` objects.
The server still only sends full snapshots, so nothing changes on the wire
yet; this is what has to land before it can start sending deltas.
The bridge now runs a `FrameSequencer` that decides what each frame should be:
a full `grid` when there is no baseline, after a resize, or on a `resync`, a
`patch` against the last frame *actually sent* otherwise, and nothing at all
when neither cells nor cursor moved.

Pacing is the other half. At most `maxUnackedFrames` (2) frames may be in
flight before the sequencer stops producing, and the ship additionally blocks
while its own socket is backed up. Because the diff baseline is the last frame
sent rather than the last one computed, skipping is lossless — the next patch
simply covers more. A `resync` reopens the window as well as forcing a full
frame, without which a client that lost sequence would wedge the stream.

The three servers also accept permessage-deflate now; Bun's client WebSocket
already offers it, so every hop compresses.

Measured over a simulated link, 4 s of build output on a 400 kbps connection:
the client used to end up 39.2 s behind having received 2.1 MB, and now ends
up 0.41 s behind having received 5 KB. Typing 24 characters cost 147 KB and
now costs 1 KB.
@FireSquid6 FireSquid6 linked an issue Jul 27, 2026 that may be closed by this pull request
@FireSquid6
FireSquid6 merged commit bd90e36 into main Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Faster webterm protocol

1 participant