Severity: Low–Medium
Category: Performance (server)
Affected files: src/server/startupSeed.ts (pushLiveLines, lines 21–31), src/server/sseHub.ts (broadcast, lines 57–74). Line numbers refer to main @ 3ac64da.
Problem
When one tailer sync cycle produces N lines, pushLiveLines loops over them and calls sseHub.broadcast('log-line', persisted) once per line; broadcast then performs 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 in logTailer.ts), which can be tens of thousands of lines. With the maximum of 10 SSE clients, a single burst produces hundreds of thousands of small response.write() calls — each with stream-state bookkeeping and eventual socket writes — all on the single event-loop thread that simultaneously serves HTTP requests. The per-write overhead dominates; the payload bytes are the same either way.
The SSE wire format concatenates frames naturally: writing
event: log-line\ndata: {...}\n\nevent: log-line\ndata: {...}\n\n
in one write is byte-identical to two separate writes. The browser's EventSource dispatches the same individual log-line events, so no client change is needed at all.
Suggested fix
- Add a batch method to
SseHub and factor the per-client delivery (slow-client check + try/write/remove) into one private helper so the drop logic stays in a single place:
broadcast(event: string, data: unknown): void {
this.writeToAll(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
broadcastBatch(event: string, items: unknown[]): void {
if (items.length === 0) {
return;
}
this.writeToAll(items.map((item) => `event: ${event}\ndata: ${JSON.stringify(item)}\n\n`).join(''));
}
private writeToAll(payload: string): void {
for (const [clientId, client] of this.clients) {
if (client.response.writableLength > MAX_CLIENT_BUFFER_BYTES) {
this.dropSlowClient(clientId, client);
continue;
}
try {
client.response.write(payload);
} catch {
this.removeClient(clientId);
}
}
}
Note the writableLength > MAX_CLIENT_BUFFER_BYTES back-pressure check now runs once per batch per client, which is also the correct granularity (checking per line was mostly redundant work).
-
In src/server/startupSeed.ts, change pushLiveLines to first push all lines into the buffer (collecting the returned persisted LogLines, which is what assigns the ids), then emit them with a single sseHub.broadcastBatch('log-line', persistedLines) call. The buffer-then-broadcast ordering per batch preserves today's invariant that a broadcast line is always already buffered (relevant for /api/resync consistency).
-
The LogLineBroadcaster interface in startupSeed.ts needs broadcastBatch added; keep broadcast for source-status and heartbeat emissions, which stay single-item.
-
Client changes: none. Events arrive individually exactly as before.
Acceptance criteria
src/server/sseHub.test.ts: broadcastBatch with N items produces a payload on the mock response that parses as N well-formed SSE log-line frames; slow-client drop still triggers when writableLength exceeds the cap; a throwing write removes the client.
src/server/startupSeed.test.ts: pushLiveLines with multiple lines results in one batch emission containing the persisted lines with their assigned ids, in order.
- Existing client tests pass unchanged.
npm test and npm run typecheck pass.
Severity: Low–Medium
Category: Performance (server)
Affected files:
src/server/startupSeed.ts(pushLiveLines, lines 21–31),src/server/sseHub.ts(broadcast, lines 57–74). Line numbers refer tomain@3ac64da.Problem
When one tailer sync cycle produces N lines,
pushLiveLinesloops over them and callssseHub.broadcast('log-line', persisted)once per line;broadcastthen performs oneJSON.stringifyplus oneresponse.write()per client per line.A burst is bounded only by
MAX_SYNC_DELTA_BYTES(8 MB of appended log per cycle inlogTailer.ts), which can be tens of thousands of lines. With the maximum of 10 SSE clients, a single burst produces hundreds of thousands of smallresponse.write()calls — each with stream-state bookkeeping and eventual socket writes — all on the single event-loop thread that simultaneously serves HTTP requests. The per-write overhead dominates; the payload bytes are the same either way.The SSE wire format concatenates frames naturally: writing
in one write is byte-identical to two separate writes. The browser's
EventSourcedispatches the same individuallog-lineevents, so no client change is needed at all.Suggested fix
SseHuband factor the per-client delivery (slow-client check + try/write/remove) into one private helper so the drop logic stays in a single place:Note the
writableLength > MAX_CLIENT_BUFFER_BYTESback-pressure check now runs once per batch per client, which is also the correct granularity (checking per line was mostly redundant work).In
src/server/startupSeed.ts, changepushLiveLinesto first push all lines into the buffer (collecting the returned persistedLogLines, which is what assigns the ids), then emit them with a singlesseHub.broadcastBatch('log-line', persistedLines)call. The buffer-then-broadcast ordering per batch preserves today's invariant that a broadcast line is always already buffered (relevant for/api/resyncconsistency).The
LogLineBroadcasterinterface instartupSeed.tsneedsbroadcastBatchadded; keepbroadcastforsource-statusandheartbeatemissions, which stay single-item.Client changes: none. Events arrive individually exactly as before.
Acceptance criteria
src/server/sseHub.test.ts:broadcastBatchwith N items produces a payload on the mock response that parses as N well-formed SSElog-lineframes; slow-client drop still triggers whenwritableLengthexceeds the cap; a throwingwriteremoves the client.src/server/startupSeed.test.ts:pushLiveLineswith multiple lines results in one batch emission containing the persisted lines with their assigned ids, in order.npm testandnpm run typecheckpass.