Skip to content

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

Description

@LarsLaskowski

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

  1. 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).

  1. 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).

  2. The LogLineBroadcaster interface in startupSeed.ts needs broadcastBatch added; keep broadcast for source-status and heartbeat emissions, which stay single-item.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions