How a phone on a bad train connection keeps seeing the newest state of a streamed reply, and why the transport's own backpressure cannot do that
Written from the measurements and the cross-checked reports of the reviewers Astra and Fable (2026-10-22): Node 24.4.1,
ws 8.22, separate server and client processes, a throttling proxy at 64 KiB/s. The numbers are single synthetic
runs: they show failure modes, not production capacity. Relays: first decided as per-hop acknowledgements (b) on
2026-10-23, then replaced by flow control per origin on 2026-10-24 after the slice 4a benchmarks (chapter VI).
Mario is on a train. His phone shows a p3 session in which the agent is writing a long reply. Every token is a small state change: the worker commits it, the server relays it, the phone applies it. The train enters a tunnel and the phone's link drops to a trickle, 64 KB per second. The worker keeps producing tokens at full speed.
Without flow control, every token still gets sent. The bytes pile up somewhere between the worker and the phone, and when the train leaves the tunnel the phone has to work through a backlog of old tokens before it sees the current text. It is not only slow: what it shows is minutes old, and nothing tells it so.
This note explains what keeps the phone current, why the obvious answer (let the transport push back) fails, and the design Astra and Fable converged on after measuring it.
A link connects two hosts. The provider owns an object and sends its state; the consumer holds a
reference to it. A hold is one reference over a link, with its own stream of ops (Delta's changes). A
snapshot is a hold's whole state. An acknowledgement (ack) is a tiny message the consumer sends after it
applied a state message. In flight means sent and not yet acknowledged. Coalescing means merging the ops that
wait to be sent. A relay is a host in the middle (the server between worker and tab).
A window of two messages in flight, and why it adapts to any link.
The rule: a provider has at most K = 2 state messages in flight per link. The consumer sends ack (three bytes in
the binary codec) after it applied one. While two are in flight, new commits do not produce messages; their ops wait,
merged per hold, and go out in the next message once an ack arrives.
Commits 1, 2, 3 each become a message; each is acknowledged before the next commit. Nothing waits: the window never fills, so a normal stream behaves exactly as without flow control.
Messages 1 and 2 are in flight. Commits 3 to 400 arrive; their ops are folded into the pending queue of each member they touch (chapter III). Memory: the net change, not the history; at most 10,000 ops per member.
ack for message 1. The provider sends message 3, carrying the folded ops of commits 3 to 400, encoded once.
The phone applies them as one batch and shows the newest text.
The slower the link, the more each message carries; the faster, the smaller and more frequent. Nobody measures bandwidth: the acknowledgements pace the sender.
The simulation above shows the same effect in miniature. Drag the link below the commit rate: the upper lane's queue grows without bound and the phone falls further behind every second; the lower lane never has more than two messages waiting, and the phone is at most a message behind.
What a waiting hold keeps while the window is full, and why it never measures sizes.
While messages are in flight, each hold member keeps the ops of new commits in a pending queue and folds them: a new op merges into the last pending op on the same path when that is safe, so a backlog keeps the net change, not the history. When an acknowledgement frees a slot, the queue is encoded once into one message.
| Last pending op | New op | Kept |
|---|---|---|
a text "Hel" |
a text "lo" |
one a text "Hello" |
s count 41 or an append |
s count 42 |
the s: it overwrites what came before |
| anything | r value |
only the r: a replacement makes everything before it moot |
| anything on a path in the index | a structural op (p, m, d, t, s of an object) |
appended; the index is cleared |
The merge partner is found through a leaf index: a map from path to the last pending append or primitive set on
that path. Leaf ops on different paths commute, so a new op may merge into an earlier one, not only into its
neighbour; a status field changing between two tokens no longer stops the reply from folding. Any structural op
clears the index, because after a splice entries[7] may name another entry. Past 10,000 pending ops the queue keeps
only "replace with the current value", which then costs nothing while the consumer stays slow.
Why no size rule. The first idea was to send a snapshot once the merged ops outgrow the member. The reviewers measured it on real p2 data and it failed three ways: "snapshot bytes plus sent op bytes" is no bound (an interned path lets a value grow faster than its ops: 5,404 B of snapshot after 832 B of ops), it drifts 1,254 times too high under churn, and an exact size needs a size on every node of the value. Folding alone came within about 10% of an exact oracle, with no measuring at all.
| 60 s stall, all workloads | Uncompressed | Compressed |
|---|---|---|
| merged ops, no folding | 466 KB | 166 KB |
| adjacent folding | 399 KB | 147 KB |
| leaf index (the decision) | 372 KB | 145 KB |
| exact oracle (not buildable) | 335 KB | 136 KB |
One queue for every slow consumer. The same queue serves Chord's shared watch handle: a listener still busy with the last frame gets the commits made meanwhile as one call with the newest value and the folded ops, instead of today's "after 100 frames, replace everything". With a listener taking one second, a conversation view's watch went from 402 calls and 17.9 MB of ops to 312 calls and 257 KB. Durable's event stream keeps a queue of its own, because events are not state: folding would drop them.
Animation of the fold: How folding works.
Why acknowledged state does not protect against a call flood.
Calls are small and the provider acknowledges nothing until they finish, so a byte or message window on state says nothing about how many handlers run. A tab with a bug:
for (const note of notes) ref.add(note, ctx); // 100,000 calls, none awaited
| 100,000 calls, handlers awaiting 10 ms | Finished in | Provider memory | Another client's p95 round trip |
|---|---|---|---|
| no limit | 0.66 s | +56.5 MB, 43,824 running | 129 ms (345 ms with trivial handlers) |
| provider-side semaphore of 64 | 15.9 s | +42.5 MB, 96,736 queued on the provider | 0.38 ms |
| caller-side window of 64 | 17.1 s | +4 MB, nothing queued on the provider | 0.66 ms |
| handlers doing 10 ms of CPU work | blocked about 1 s, with or without a limit |
The provider's hello announces a call window N (256 by default) per origin (chapter VI). A consumer keeps at
most N of its own calls unanswered per link; further calls wait in the link's call queue, in order. Nothing is refused (K16); a peer that sends
more than N is a protocol error. The flood waits in the tab that caused it. CPU-heavy handlers block the event loop
whatever the window: that stays the handler author's responsibility.
One link to a worker carries many tabs. Decided: flow control per origin.
The server relays every tab's holds to a session's worker over one link. Three ways to apply the acknowledged window across that hop were considered:
How (c) works:
origin), and the worker keys holds, calls, windows and acks by (link, origin, id). Ids are not renumbered.hold message (to route calls and
hold again after a worker restart); per tab a FIFO of which worker sent each message in flight, so each in-order
ack goes back to the right worker. Nothing is parsed, folded, dropped or reset.closed { origin } to each worker it relayed to, which releases
that origin's holds and aborts its calls.The converged scheme, what it replaces, and the relay decision.
Converged (Astra and Fable, after cross-checking each other's experiments): at most K = 2 acknowledged state
messages in flight; pending ops folded per hold member through a leaf index, capped at 10,000; a call window N
announced in hello, extra calls queued by the caller; holds unlimited; size limits stay a separate check.
Replaced from the design (§3, §22): the byte window and its credit messages, snapshot admission and debt (R19, Q3), cut C12's call-credit accounting, any adapter backlog API. IPC and WebSocket behave the same, because acknowledgements are messages.
Relays (owner): per-hop acknowledgements (b) were decided on 2026-10-23 and built in slice 4a; the benchmarks showed a slow tab recovering through resets far worse than a direct tab (24 s and three snapshots after a 1 s stall). On 2026-10-24 they were replaced by flow control per origin (c): the worker keeps window, pending queues and calls per (link, origin), the relay only forwards and returns acks. Measured after the rework: a slow tab through the relay catches up exactly like a direct one. The circuit breaker stayed undecided; the heartbeat (15 s ping, 30 s close) was built instead.
| Experiment | Setup | Result |
|---|---|---|
| normal stream | 50 × 23 B/s through 64 KiB/s | no buffering, frames ≤ 2 ms old |
| flood, no control | ~9,800 × 128 B/s, 10 s | 7.0 MB in bufferedAmount, heap +18.5 MB, newest state 9.4 s old |
flood, bufferedAmount ≤ 64 KiB + merging |
same | heap +0.46 MB, newest state 9.4 s old |
| flood, 1 to 4 acked messages + merging | same | heap +0.4 MB, newest state 0.0 s old, 98 % fewer messages |
| client stops reading (loopback) | 20k × 90 B/s, 15 s | 23.4 MiB buffered, RSS +121 MB; merging above 1 MiB: flat |
| IPC to a busy child | 623k sends | send() false from ~64 KB, parent +1,035 MB; the callback acknowledges the write, not the handler |
| call flood | 100,000 calls, see chapter IV | caller-side window: +4 MB, other client p95 0.66 ms |
| slow tab through a relay, (b) resets | 64 KiB/s, 100 tokens/s into a 500 KB view, 1 s stall | ~24 s to catch up, three full snapshots (~1.5 MiB) |
| slow tab through a relay, (c) per origin | same, 1 s / 10 s / 60 s stall | caught up after 0.02 / 0.08 / 0.38 s, 485 B / 4.1 KiB / 23.7 KiB (same as direct) |
Flow control for Chord links · fourth edition · 2026-10-24 · based on the reviewers' experiments and design §3, §22