Node.js Streaming Architecture Basics Permalink to this section

Part of Backend Stream Generation & Connection Management.

Node.js is well-suited for SSE servers because its non-blocking I/O model holds thousands of long-lived connections open with minimal per-connection overhead. The challenge is not concurrency per se — it is correctness: sending the right headers the moment a connection opens, flushing each event frame immediately, cleaning up every timer and subscription when a client leaves, and respecting TCP backpressure so a slow reader cannot exhaust the process heap. This guide covers the complete mechanism from wire format to production ops.

How SSE Works in Node.js Permalink to this section

The WHATWG HTML specification defines SSE as an HTTP response with Content-Type: text/event-stream that the server keeps open indefinitely, writing newline-delimited event frames in UTF-8. Each frame is one or more field: value lines followed by a blank line (\n\n) that signals the end of the event to the client parser.

Node.js exposes this through the standard http.ServerResponse object. Writing to res pushes bytes into the socket’s kernel send buffer. With Transfer-Encoding: chunked (the default for HTTP/1.1 responses with no Content-Length), each res.write() call becomes one chunk on the wire. The browser’s EventSource API feeds those chunks into its incremental line parser without waiting for the response to end.

Wire-Level Frame Format Permalink to this section

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked

id: 42\n
event: price\n
data: {"symbol":"BTC","price":67420}\n
\n
: heartbeat\n
\n
id: 43\n
event: price\n
data: {"symbol":"ETH","price":3812}\n
\n

The comment line (: heartbeat) is ignored by EventSource but resets proxy idle timers. The blank line after each event is mandatory — without it the browser accumulates lines in its buffer and dispatches nothing.

Node.js Stream Internals Permalink to this section

res is a http.OutgoingMessage which extends stream.Writable. Each call to res.write(chunk) travels this path:

  1. Serialises chunk into HTTP chunk framing (<hex-length>\r\n<data>\r\n).
  2. Pushes into the socket’s stream.Duplex write buffer.
  3. If the kernel TCP send buffer is full, res.write() returns false and Node emits drain on the socket when space is available.

You cannot call res.end() — that closes the connection. You also cannot call res.flush()http.ServerResponse has no such method. The correct pattern is res.write(), which triggers an immediate kernel send() syscall in most configurations.

Node.js SSE connection lifecycle Diagram showing the flow from browser EventSource through Node.js http.Server, event loop, and back-pressure signalling to the socket kernel buffer. Browser EventSource /events GET Reverse Proxy nginx / ALB proxy_buffering off http.Server req / res objects writeHead + write() Event Loop setInterval (heartbeat) EventEmitter callbacks Kernel TCP Buffer send() / drain event write()=false pause / drain Data Source Redis / DB / Queue EventEmitter / async iter events data flow backpressure signal upstream events
Node.js SSE connection lifecycle: request path, event loop dispatch, kernel TCP backpressure signal.

Server-Side Implementation Permalink to this section

Minimal SSE Endpoint with Node’s http Module Permalink to this section

import http from 'node:http';
import { randomUUID } from 'node:crypto';

// In-memory client registry: Map<string, http.ServerResponse>
const clients = new Map();

const server = http.createServer((req, res) => {
  if (req.url !== '/events') {
    res.writeHead(404).end();
    return;
  }

  // 1. Negotiate the SSE handshake immediately
  res.writeHead(200, {
    'Content-Type':  'text/event-stream',
    'Cache-Control': 'no-cache, no-transform', // no-transform stops proxies gzipping
    'Connection':    'keep-alive',
    'X-Accel-Buffering': 'no',                 // disable nginx proxy_buffering for this res
  });

  // 2. Flush headers so the browser's EventSource unblocks
  res.flushHeaders();

  const clientId = randomUUID();
  clients.set(clientId, res);

  // 3. Send retry hint so the browser backs off 5 s on reconnect
  res.write('retry: 5000\n\n');

  // 4. Heartbeat — comment lines reset proxy idle timers without triggering EventSource handlers
  const heartbeat = setInterval(() => {
    res.write(': heartbeat\n\n');
  }, 20_000); // every 20 s; set below most proxy 30 s idle timeouts

  // 5. Cleanup on client disconnect
  req.on('close', () => {
    clearInterval(heartbeat);
    clients.delete(clientId);
    console.log({ event: 'sse_disconnect', clientId, total: clients.size });
  });
});

// Broadcast helper used by your business logic
export function broadcast(eventName, payload, eventId) {
  const frame = [
    `id: ${eventId}`,
    `event: ${eventName}`,
    `data: ${JSON.stringify(payload)}`,
    '',
    '',                  // trailing blank line = end of event
  ].join('\n');

  for (const [id, res] of clients) {
    const ok = res.write(frame);
    if (!ok) {
      // Kernel buffer full — slow consumer
      // Drop or queue; see backpressure section below
      console.warn({ event: 'sse_backpressure', clientId: id });
    }
  }
}

server.listen(3000);

Express Integration Permalink to this section

Express wraps http.ServerResponse identically, but the compression middleware must be excluded from SSE routes — it buffers output until its threshold is met.

import express from 'express';
import compression from 'compression';

const app = express();

// Apply compression everywhere EXCEPT SSE endpoints
app.use(compression({
  filter: (req, res) =>
    req.path !== '/events' && compression.filter(req, res),
}));

app.get('/events', (req, res) => {
  res.set({
    'Content-Type':      'text/event-stream',
    'Cache-Control':     'no-cache, no-transform',
    'Connection':        'keep-alive',
    'X-Accel-Buffering': 'no',
  });
  res.flushHeaders();

  let seq = 0;

  const ticker = setInterval(() => {
    seq++;
    res.write(`id: ${seq}\ndata: ${Date.now()}\n\n`);
  }, 1_000);

  req.on('close', () => clearInterval(ticker));
});

app.listen(3000);

Async-Iterator Pattern (Node ≥ 16) Permalink to this section

For event sources that expose an async iterable (database change feeds, message-queue consumers), you can drive the SSE loop with for await:

app.get('/feed', async (req, res) => {
  res.set({
    'Content-Type':      'text/event-stream',
    'Cache-Control':     'no-cache, no-transform',
    'X-Accel-Buffering': 'no',
  });
  res.flushHeaders();

  // AbortController lets us stop the async iterator when the client leaves
  const ac = new AbortController();
  req.on('close', () => ac.abort());

  try {
    for await (const msg of subscribeToQueue({ signal: ac.signal })) {
      // Honour backpressure: await a Promise that resolves on 'drain'
      if (!res.write(`id: ${msg.id}\ndata: ${JSON.stringify(msg.body)}\n\n`)) {
        await new Promise(resolve => res.once('drain', resolve));
      }
    }
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
    // AbortError is expected when the client disconnects
  }
});

Client EventSource Usage Permalink to this section

// Browser-side: consume the /events endpoint
const es = new EventSource('/events', { withCredentials: true });

es.addEventListener('price', (e) => {
  const { symbol, price } = JSON.parse(e.data);
  console.log(symbol, price, 'lastEventId:', e.lastEventId);
});

es.addEventListener('error', (e) => {
  // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSED
  if (es.readyState === EventSource.CLOSED) {
    console.error('Stream closed permanently — check CORS or 4xx status');
  }
  // readyState=CONNECTING means the browser is applying the retry interval
});

The browser automatically sends Last-Event-ID: <last id> on every reconnect. Read it server-side via req.headers['last-event-id'] to replay missed events from a buffer or message queue.

app.get('/events', (req, res) => {
  const resumeFrom = req.headers['last-event-id'];

  res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform' });
  res.flushHeaders();

  if (resumeFrom) {
    // replay any events with id > resumeFrom from your event store
    replayMissedEvents(resumeFrom, res);
  }

  // ...attach live subscription
});

For a detailed implementation of idempotent ID generation to make replay reliable, see Idempotent Event ID Generation.

Edge Cases & Network Interference Permalink to this section

Proxies and CDNs are the largest source of SSE failures in production. The table below lists every common layer and the required mitigation.

Layer Default Behaviour Mitigation
nginx (proxy) proxy_buffering on — buffers entire response proxy_buffering off; on the location block
nginx (gzip) gzip on may buffer chunks gzip off; for text/event-stream
AWS ALB Idle connection timeout default 60 s Set to ≥ 120 s; send heartbeat every 30 s
Cloudflare (free) 100 s response timeout Use X-Accel-Buffering: no; upgrade to Enterprise for longer
Fastly / Varnish Caches 200 responses Add Surrogate-Control: no-store header
Corporate HTTPS proxies May strip Transfer-Encoding: chunked Use HTTP/2 (framing replaces chunked encoding)
Node’s own zlib stream (if piped) Buffers until flush threshold Never pipe SSE responses through compression streams

nginx Configuration for SSE Permalink to this section

location /events {
    proxy_pass         http://node_backend;
    proxy_http_version 1.1;
    proxy_set_header   Connection '';      # disable hop-by-hop Connection header
    proxy_buffering    off;
    proxy_cache        off;
    proxy_read_timeout 3600s;              # 1-hour idle timeout
    gzip               off;
    chunked_transfer_encoding on;
}

Heartbeat Timing Permalink to this section

Most proxies close connections idle for 30–60 s. Send a comment heartbeat every 20 seconds to stay under all common thresholds:

const heartbeat = setInterval(() => res.write(': \n\n'), 20_000);

A comment (:), not a data: field, avoids triggering EventSource.onmessage or named event listeners on the client.

req.socket.setTimeout vs Proxy Timeouts Permalink to this section

Node’s built-in socket timeout closes the socket from the server side. Never set req.socket.setTimeout() shorter than your heartbeat interval or you will self-disconnect clients. Set it to 0 (disabled) for SSE routes and rely on application-level heartbeats instead:

req.socket.setTimeout(0); // disable Node's socket idle timeout for SSE

For a complete treatment of HTTP Keep-Alive & Connection Lifecycle, including keepAliveTimeout tuning at the http.Server level, see that dedicated guide.

Performance & Scale Considerations Permalink to this section

Connection Count and Memory Permalink to this section

Each persistent SSE connection holds open a TCP socket and a Node.js http.ServerResponse object. The RSS overhead is approximately 6–10 KB per connection for the socket buffers alone, excluding any application state you attach (subscription handles, timers, queued events).

Connections Approx. socket memory Notes
1 000 ~8 MB Comfortable on a 512 MB container
10 000 ~80 MB Requires ulimit -n 65535; tune --max-old-space-size
100 000 ~800 MB Needs horizontal scaling; see Connection Pooling for SSE Servers

Raise the OS file-descriptor limit before deploying:

# /etc/systemd/system/myapp.service
[Service]
LimitNOFILE=65535

Backpressure Handling Permalink to this section

res.write() returns false when the kernel TCP send buffer is saturated (the client is reading slower than you write). Ignoring this will grow Node’s internal write buffer until OOM.

function writeWithBackpressure(res, frame) {
  return new Promise((resolve, reject) => {
    const ok = res.write(frame, (err) => {
      if (err) reject(err);
    });
    if (ok) {
      resolve();
    } else {
      res.once('drain', resolve);
    }
  });
}

// In your event dispatch loop:
try {
  await writeWithBackpressure(res, frame);
} catch {
  // Client disconnected during drain — req.on('close') will clean up
}

For token-bucket rate limiting to bound how many events/second each connection receives, see Rate Limiting & Backpressure Handling.

CPU — JSON Serialization at Scale Permalink to this section

Broadcasting identical JSON to 10 000 clients means 10 000 JSON.stringify() calls per event if you serialise inside the loop. Serialise once outside the loop:

function broadcastEfficient(eventName, payload, eventId) {
  const frame = `id: ${eventId}\nevent: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
  for (const res of clients.values()) {
    res.write(frame); // same Buffer reference reused internally by Node
  }
}

Fan-Out to Multiple Nodes Permalink to this section

A single Node process cannot fan-out events that originate on a different server instance. Use Redis Pub/Sub Fan-Out for SSE to publish to a channel and subscribe from every SSE-serving process.

Validation & Debugging Permalink to this section

curl — Raw Stream Inspection Permalink to this section

# -N disables buffering; -v shows response headers
curl -N -v \
  -H "Accept: text/event-stream" \
  -H "Last-Event-ID: 41" \
  http://localhost:3000/events

Expected output includes:

  • < Content-Type: text/event-stream
  • < Transfer-Encoding: chunked
  • < X-Accel-Buffering: no
  • Heartbeat comment lines (: ) every ~20 s
  • Event frames with id:, event:, data: fields

Chrome DevTools Permalink to this section

  1. Open Network → filter by EventStream.
  2. Select the /events request → EventStream tab.
  3. Verify events appear in real time; timestamps should match your server-side emit intervals.
  4. Check Headers for Transfer-Encoding: chunked (HTTP/1.1) or no content-length (HTTP/2).

Structured Logging Permalink to this section

Emit structured JSON log lines at connection open, each event sent, backpressure events, and disconnect:

// pino example
const log = pino({ level: 'info' });

req.on('close', () => {
  log.info({
    event: 'sse_disconnect',
    clientId,
    durationMs: Date.now() - connectedAt,
    eventsDelivered: seq,
    totalClients: clients.size,
  });
});

Load Testing Permalink to this section

# k6 script — 500 virtual users each holding a 30 s SSE connection
k6 run --vus 500 --duration 30s - <<'EOF'
import http from 'k6/http';
export default function () {
  const res = http.get('http://localhost:3000/events', {
    headers: { Accept: 'text/event-stream' },
    timeout: '35s',
  });
}
EOF

Monitor process.memoryUsage().heapUsed before and after; a linear increase proportional to connection count is expected. A super-linear increase indicates a closure leak — check that every setInterval, event listener, and subscription is removed in req.on('close').

Reconnect Test Permalink to this section

# Start a connection, kill the server, restart it, verify browser reconnects
# Check the Last-Event-ID header in the second request:
curl -N -v http://localhost:3000/events 2>&1 | grep -i last-event

For a deep dive on disconnect cleanup patterns, see Handling Client Disconnects in Node.js SSE.

⚡ Production Directives

  • Always call res.flushHeaders() immediately after writeHead() — do not wait for the first res.write() or the browser's EventSource will stall until the proxy's buffer threshold is met.
  • Set X-Accel-Buffering: no in every SSE response; configure proxy_buffering off in nginx and disable gzip for text/event-stream routes.
  • Send a comment heartbeat every 20 seconds to prevent proxy idle-timeout disconnects; never rely on TCP keepalives alone — they fire too slowly for most proxy defaults.
  • Honour backpressure: check the return value of res.write() and await the drain event before writing more data to a slow consumer; unbounded write loops will OOM the process.
  • Remove every setInterval, pub/sub subscription, and event listener inside req.on('close') — resource leaks compound rapidly under sustained load and will not surface in short load tests.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Why does my EventSource not receive any events until I close the browser tab?

This is a proxy-buffering issue. The reverse proxy (nginx, ALB, Cloudflare) is accumulating the response body before forwarding it. Set proxy_buffering off in nginx and add X-Accel-Buffering: no to the response headers. Also ensure you are calling res.flushHeaders() before the first write and that the compression middleware is not applied to the SSE route.

What is the difference between a comment heartbeat (: ) and a data event heartbeat?

A comment line (: anything\n\n) is completely ignored by the browser's EventSource parser — it will not fire onmessage or any named event listener. It exists solely to keep the TCP connection alive and reset proxy idle timers. A data: event would trigger your JavaScript handlers and pollute your application logic with no-op messages. Always use comment lines for heartbeats.

Can I use HTTP/2 for SSE and does it change anything?

Yes. HTTP/2 replaces Transfer-Encoding: chunked with its own binary frame multiplexing, so you never set Connection: keep-alive manually — HTTP/2 connections are persistent by design. Node's built-in http2 module exposes a ServerHttp2Stream instead of http.ServerResponse; write to it the same way. One benefit: HTTP/2 multiplexing means multiple SSE streams share a single TCP connection from the browser, reducing OS socket overhead on the client. Proxy buffering issues largely disappear because HTTP/2-aware proxies stream frames directly.

How do I fan-out one event to all connected clients efficiently?

Serialise the event frame once (outside the loop), then call res.write(frame) for each client. Node reuses the same internal Buffer, so you avoid N allocations. For events generated on other server instances (in a horizontally-scaled fleet), subscribe each instance to a shared Redis channel and write to local clients only — see Redis Pub/Sub Fan-Out for SSE.

When should I use EventSource vs fetch with a ReadableStream?

EventSource is the right default: it handles reconnect, Last-Event-ID, retry intervals, and named events automatically. Use fetch + ReadableStream when you need POST semantics (sending a large body before streaming begins), request cancellation via AbortController in environments that support it, or when you need SSE-style streaming over HTTP/2 push. The text/event-stream wire format is the same either way — you parse data: lines manually in the fetch case.

Deep Dives