Send webterm frames as deltas, paced against the client - #32
Merged
Conversation
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.
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
gridgains a per-connectionseq, and a newpatchframe carries runs ofconsecutive changed cells (
[row, col, cells]) against the frame numberedseq - 1. Runsrather than per-cell entries, because a scroll or a repainted status line changes whole spans
at once. A
gridis still self-syncing and is what goes out first, after a resize, and onrequest.
Pacing. The bridge now runs a
FrameSequencer. At most 2 frames may be in flightunacked before it stops producing, and the ship additionally blocks while its own socket is
backed up (>256 KiB). Clients
ackevery frame they apply. Crucially the diff baseline isthe 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
seqsendsresyncand paints nothing until afull
gridarrives; exactly one request goes out per gap, so a congested link doesn't get aburst of them.
resyncreopens the ack window as well as forcing a full frame — withoutthat, 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 → shipcompresses 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.
bun-vtterminal with realistic PTY output and serializesevery frame exactly as
TerminalBridgedoes. "after" is the delta frame underpermessage-deflate with context takeover, which is what a browser negotiates.
TerminalBridgeagainst a real PTY and pipes its framesthrough 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
lswith colors120×40
lswith colors200×50
lswith colorsWhat 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-upis howfar behind the client's view is when the output stops.
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
catat a human cadence.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
fleet-protocolnegotiates versions today, and thisfollows 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
initcapability flag would be~30 lines if that assumption is wrong.
typing frame is 23–27 bytes; there is nothing left for a binary format to win.
such a client degrades to one frame per 5 s instead of freezing. Deliberate, commented.
congested()blocks a frame while no frames are unacked, nothingre-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 theServerWebSocketdeclaration 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 "Framesare full snapshots" section is rewritten),
reference/ship-api.md.bun run typecheck0 errors,bun run test746 pass / 0 fail.🤖 Generated with Claude Code