Skip to content

perf: coalesce streamed message_update events to cut remote bandwidth (#375) - #393

Closed
shani-singh1 wants to merge 1 commit into
agegr:mainfrom
shani-singh1:fix/375-coalesce-message-updates
Closed

perf: coalesce streamed message_update events to cut remote bandwidth (#375)#393
shani-singh1 wants to merge 1 commit into
agegr:mainfrom
shani-singh1:fix/375-coalesce-message-updates

Conversation

@shani-singh1

Copy link
Copy Markdown
Contributor

Summary

Fixes #375.

On remote access (VPN / metered / public network), pi-web's SSE stream is hugely bandwidth-heavy: every message_update event carries the full accumulated message, and the agent emits one per streamed chunk. The events route forwarded each one, so the browser received the entire message again on every chunk — transfer grows O(n²) with message size. The reporter measured ~150× amplification over the actual content (~15 KB content → ~2.2 MB transferred; >6 MB with thinking: high).

Fix (server-side only)

Coalesce streamed message_update events in the events route:

  • Keep only the latest pending message_update (each already carries the full message, so older ones are redundant) and flush it on a short ~80 ms timer — a burst of chunks collapses into one send, while streaming stays smooth (~12/s).
  • Any other event (tool / turn / agent_end) flushes the pending update first, so a message_update is never reordered past a later event, and the message's final content is always delivered before the next boundary event.
  • The flush timer is cleared on client disconnect.

No protocol or frontend change: the client still renders each message_update it receives exactly as before — it just receives far fewer of them, which also reduces client re-render cost. Full delta rendering (frontend appends assistantMessageEvent deltas) would take this to 1× and is a natural follow-up; this change is the contained, low-risk server-side win.

The coalescing logic is extracted into lib/event-coalescer.ts so it can be unit-tested independently of the SSE plumbing.

Testing

  • tsc --noEmit passes
  • eslint passes
  • node --test lib/event-coalescer.test.mjs — 5/5 pass, covering: buffering until flush, coalescing consecutive updates to the latest, flushing pending before a following non-update event (ordering), non-update passthrough, and no-op flush.

Every message_update carries the full accumulated message, and the agent
emits one per streamed chunk. The events route forwarded each one, so a
remote client received the whole message again on every chunk — transfer
grew O(n^2) with message size (~150x amplification over the actual
content on VPN/metered connections, more with thinking). agegr#375

Buffer message_update events and flush only the latest on an ~80ms timer,
so a burst of chunks collapses into one send while streaming stays smooth
(~12/s). Any other event (tool/turn/agent_end) flushes the pending update
first, so a message_update is never reordered past a later event and the
message's final content is always delivered before the next boundary.

Server-side only; the client still renders each message_update as before,
just fewer of them (which also cuts client re-render cost). Frontend
delta rendering would drive this to 1x and is a natural follow-up.

Fixes agegr#375
@agegr

agegr commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Thanks for the contribution and for keeping the proposed change server-side and contained. Coalescing full message_update snapshots is a reasonable short-term mitigation and has a smaller synchronization risk than introducing a custom client protocol.

However, a fixed 80 ms window only reduces the constant factor. It still sends a growing full-message snapshot repeatedly, so total transfer remains O(n²). When update intervals are 80 ms or slower, little or no coalescing occurs. Because of that, this cannot be the final fix for #375.

pi-web has now upgraded to Pi 0.84, which defines an official delta-only JSON/RPC contract for message_update. We will align the SSE transport with that contract instead:

  • keep the message_update event name
  • forward only assistantMessageEvent without partial
  • use message_start as the baseline
  • treat message_end.message as the authoritative final state
  • send one full baseline on reconnect

This provides linear wire usage without maintaining repeated full snapshots. I’m therefore closing this PR in favor of the replacement implementation. We will keep #375 open until that implementation lands.

Thank you for the clear implementation and tests.

@agegr agegr closed this Aug 8, 2026
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.

远程访问时带宽放大 ~150 倍:events 流每次更新都重发完整消息

2 participants