Problem
pi emits the full cumulative partial assistant message on every message_update, and pi-web broadcasts that envelope verbatim to every connected WebSocket client (server/session/hostEvents.ts forwards the pi event; server/realtime.ts:39-43 does one JSON.stringify + send per client). Over a response of length L this serializes and transmits every prefix of the message — O(L²) total bytes per response per client.
The RealtimeHub replay log also retains the last 1,000 envelopes for reconnect catch-up, which under streaming means holding many large cumulative partials in memory even though a reconnecting client re-syncs state anyway.
Measured evidence (isolated benchmark, origin/main @ 32f7afb)
Fixed-rate local provider (100 tok/s), one WS client, isolated instance:
| Response length |
WebSocket wire bytes |
| 1,000 tokens |
~3.68 MiB |
| 10,000 tokens |
208.44 MiB (~56.7× for 10× the tokens) |
Related pressure at fan-out (same benchmark, 3×1,000-token turns per session):
| Concurrent sessions |
Per-session tok/s |
Bystander HTTP p99 |
Server CPU mean |
| 1 |
100.10 |
0.76 ms |
6.7% |
| 8 |
99.85 |
64.2 ms |
24.1% |
| 16 |
99.86 |
200.4 ms |
43.6% |
Throughput is not yet limited (per-session rate stays flat through N=16, CPU has headroom, and delivery lag stayed ~1 ms p99 even at token 10,000 of a 10k response). But the superlinear wire volume is the first known scaling cliff: faster models, longer responses, or more connected clients multiply it directly, and it is the most plausible driver of the p99 growth above.
Proposed fix
- Delta-only streaming transport: for
message_update, broadcast only the text/thinking delta plus session id, message index, and a sequence number. Clients accumulate locally. The complete message still arrives on message_end (already broadcast), so any drift self-heals at message boundaries; the existing seq/sync_required machinery covers reconnects.
- Exclude
message_update envelopes from the reconnect replay log — a reconnecting client refreshes state/messages anyway; replaying stale partials is pure cost.
- Optional hardening: coalesce/throttle
message_update broadcasts (e.g. ≥30–50 ms per session) so worst-case fan-out work is bounded independent of provider chunk rate.
Wire-compat note: gate on a client capability flag or bump the envelope version so older clients keep receiving cumulative partials until updated.
Acceptance criteria
Relation to prior issues
Problem
pi emits the full cumulative partial assistant message on every
message_update, and pi-web broadcasts that envelope verbatim to every connected WebSocket client (server/session/hostEvents.tsforwards the pi event;server/realtime.ts:39-43does oneJSON.stringify+ send per client). Over a response of length L this serializes and transmits every prefix of the message — O(L²) total bytes per response per client.The
RealtimeHubreplay log also retains the last 1,000 envelopes for reconnect catch-up, which under streaming means holding many large cumulative partials in memory even though a reconnecting client re-syncs state anyway.Measured evidence (isolated benchmark, origin/main @ 32f7afb)
Fixed-rate local provider (100 tok/s), one WS client, isolated instance:
Related pressure at fan-out (same benchmark, 3×1,000-token turns per session):
Throughput is not yet limited (per-session rate stays flat through N=16, CPU has headroom, and delivery lag stayed ~1 ms p99 even at token 10,000 of a 10k response). But the superlinear wire volume is the first known scaling cliff: faster models, longer responses, or more connected clients multiply it directly, and it is the most plausible driver of the p99 growth above.
Proposed fix
message_update, broadcast only the text/thinking delta plus session id, message index, and a sequence number. Clients accumulate locally. The complete message still arrives onmessage_end(already broadcast), so any drift self-heals at message boundaries; the existingseq/sync_requiredmachinery covers reconnects.message_updateenvelopes from the reconnect replay log — a reconnecting client refreshes state/messages anyway; replaying stale partials is pure cost.message_updatebroadcasts (e.g. ≥30–50 ms per session) so worst-case fan-out work is bounded independent of provider chunk rate.Wire-compat note: gate on a client capability flag or bump the envelope version so older clients keep receiving cumulative partials until updated.
Acceptance criteria
message_endreconciles the final message.message_update+ state refresh).Relation to prior issues