Skip to content

perf(sseHub): batch live log lines into chunked SSE writes - #157

Merged
LarsLaskowski merged 2 commits into
mainfrom
claude/issue-135-2yi6xm
Jul 30, 2026
Merged

perf(sseHub): batch live log lines into chunked SSE writes#157
LarsLaskowski merged 2 commits into
mainfrom
claude/issue-135-2yi6xm

Conversation

@LarsLaskowski

@LarsLaskowski LarsLaskowski commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Summary

Live log lines produced by one tailer sync cycle are now broadcast to each SSE client in chunked writes (one per 256 KB of payload) instead of one write per line.

Why

pushLiveLines looped over the lines of a sync cycle and called sseHub.broadcast('log-line', …) once per line, so broadcast did one JSON.stringify plus one response.write() per client per line. A burst is bounded only by MAX_SYNC_DELTA_BYTES (8 MB of appended log per cycle), which can be tens of thousands of lines; with up to 10 connected clients that meant hundreds of thousands of small writes on the single event-loop thread that also serves HTTP requests. The per-write bookkeeping dominates — the payload bytes are identical either way.

SSE frames concatenate naturally: writing several event: …\ndata: …\n\n frames in one write is byte-identical to writing them separately, and the browser's EventSource still dispatches the same individual log-line events. No client change is needed.

Chunking rather than a single write per batch is deliberate. MAX_CLIENT_BUFFER_BYTES (1 MB) only bounds a stalled client's outgoing buffer if writableLength is re-checked often enough; writing an entire sync cycle at once would hand a stalled client several megabytes before the next check and turn that constant into a per-cycle floor instead of a ceiling. Flushing every 256 KB keeps essentially all of the syscall win — a 10k-line burst becomes a few dozen writes rather than one — while the cap keeps its meaning.

Changes

  • src/server/sseHub.ts: added broadcastBatch(event, items), which appends frames to a rolling chunk and flushes to all clients every WRITE_CHUNK_CHARS (256 KB), plus a final flush for the remainder; an empty batch writes nothing. Accumulating into a chunk rather than items.map(…).join('') also avoids materializing the per-frame array and the joined payload at the same time. Extracted the per-client delivery loop — back-pressure check, try/write, client removal — into a private writeToAll helper shared by broadcast and broadcastBatch, and the frame formatting into a formatEvent helper.
  • src/server/startupSeed.ts: pushLiveLines pushes the whole batch into the buffer first (collecting the persisted lines with their assigned ids), then emits them with one broadcastBatch('log-line', …) call. Buffering before broadcasting preserves the invariant that a broadcast line is always already buffered, which /api/resync relies on. The local LogLineBroadcaster interface now requires broadcastBatch; SseHub.broadcast remains for the single-item source-status and heartbeat emissions.

Tests

  • src/server/sseHub.test.ts: a small broadcastBatch produces exactly one write that parses as three well-formed log-line frames; an empty batch writes nothing; a batch broadcast still drops a slow client whose writableLength exceeds the cap and removes a client whose write throws; a 20-frame / ~2 MB batch to a client that never drains is split across several writes (fewer than one per frame) and the client is dropped part way through, with its buffer still bounded well under the batch size.
  • src/server/startupSeed.test.ts: the broadcaster double now records batches, and the seeded-pusher test asserts that two live lines result in a single batch emission carrying the persisted lines with ids 1 and 2, in order.
  • npm run typecheck, npm test (139 tests), and npm run build all pass.

Linked issues

Closes #135

Review notes

The wire output is unchanged, so no client-side change was required and the existing client tests pass untouched. Ordering is also unchanged: lines are buffered and broadcast in the same sequence as before, just grouped into chunks.

The issue text proposed a single write per batch and called the resulting per-batch back-pressure check "the correct granularity". This implementation deviates on that point for the memory-bound reason above; everything else follows the proposal.

Follow-up work

None.

claude added 2 commits July 30, 2026 17:33
A tailer sync cycle can produce tens of thousands of lines, and each one
was broadcast separately: one JSON.stringify and one response.write per
client per line. Concatenated SSE frames are byte-identical to the same
frames written individually, so the new broadcastBatch emits the whole
batch in a single write per client while EventSource still dispatches the
events one by one. No client change is needed.

pushLiveLines now buffers the whole batch before broadcasting it, keeping
the invariant that a broadcast line is already buffered for /api/resync.
The per-client back-pressure check moves into a shared writeToAll helper
and runs once per batch, which is the right granularity.
… meaningful

Writing a whole batch in one call let a stalled client be handed a full
sync cycle (up to MAX_SYNC_DELTA_BYTES per source) before the next
back-pressure check, turning MAX_CLIENT_BUFFER_BYTES into a per-cycle
floor rather than a ceiling. Frames are now appended to a rolling chunk
and flushed every 256 KB, so writableLength is re-checked at that
granularity while a burst still collapses into a few dozen writes instead
of tens of thousands. Accumulating into a chunk also avoids materializing
the per-frame array and the joined payload at the same time.
@LarsLaskowski LarsLaskowski changed the title perf(sseHub): batch live log lines into one SSE write per client perf(sseHub): batch live log lines into chunked SSE writes Jul 30, 2026
@sonarqubecloud

Copy link
Copy Markdown

@LarsLaskowski
LarsLaskowski merged commit 61dfdad into main Jul 30, 2026
10 checks passed
@LarsLaskowski
LarsLaskowski deleted the claude/issue-135-2yi6xm branch July 30, 2026 18:15
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.

Batch SSE broadcasts: one write per line per client causes excessive syscall/CPU overhead during log bursts

2 participants