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:
- Serialises
chunkinto HTTP chunk framing (<hex-length>\r\n<data>\r\n). - Pushes into the socket’s
stream.Duplexwrite buffer. - If the kernel TCP send buffer is full,
res.write()returnsfalseand Node emitsdrainon 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.
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
- Open Network → filter by EventStream.
- Select the
/eventsrequest → EventStream tab. - Verify events appear in real time; timestamps should match your server-side emit intervals.
- 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 afterwriteHead()— do not wait for the firstres.write()or the browser's EventSource will stall until the proxy's buffer threshold is met. - Set
X-Accel-Buffering: noin every SSE response; configureproxy_buffering offin nginx and disable gzip fortext/event-streamroutes. - 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 thedrainevent before writing more data to a slow consumer; unbounded write loops will OOM the process. - Remove every
setInterval, pub/sub subscription, and event listener insidereq.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.