Implementing HTTP Keep-Alive for Node.js SSE Permalink to this section
Part of HTTP Keep-Alive & Connection Lifecycle.
SSE connections on Node.js silently drop after 60β120 seconds of payload inactivity. This guide walks through every layer β server timeout settings, application-level heartbeat comments, and proxy configuration β needed to keep those connections alive indefinitely without triggering false reconnect loops.
Symptom & Developer Intent Permalink to this section
Observed behavior: EventSource instances in the browser fire onerror after roughly 60 seconds of no events being sent, then immediately attempt to reconnect. The server log shows a new connection arriving every minute. Under load, this produces a thundering-herd reconnect pattern that can exhaust connection pool capacity and spike CPU.
Error signals you will see:
- Browser DevTools Network tab: SSE request ends with status
(failed)ornet::ERR_EMPTY_RESPONSEbeforeretry:fires. - Nginx error log:
upstream prematurely closed connection while reading response headeror504 Gateway Time-out. - AWS ALB access log:
Target.Timeoutin theerror_reasonfield after the ALB idle timeout (default 60 s). EventSource.readyStatetransitions from1(OPEN) to0(CONNECTING) unexpectedly.
Developer intent: Maintain the TCP connection through infrastructure idle timeouts by emitting periodic SSE comment frames (": keep-alive\n\n") and by setting server.keepAliveTimeout to a value larger than any upstream idle threshold.
Root Cause Analysis Permalink to this section
Why the connection drops Permalink to this section
The text/event-stream wire format carries no built-in heartbeat. When your application has no events to push, zero bytes traverse the socket. Every hop between the client and server β NAT gateway, AWS ALB, Nginx reverse proxy, Kubernetes ingress controller β maintains its own idle-connection timer. When that timer fires, the hop closes the TCP connection with a FIN (graceful) or RST (abrupt). The browserβs EventSource implementation sees the EOF and schedules a reconnect using the retry interval (default 3 000 ms unless you sent a retry: field).
| Infrastructure layer | Default idle timeout | Configurable? |
|---|---|---|
| AWS ALB | 60 s | Yes β up to 4000 s |
| GCP HTTP(S) LB | 600 s | No |
Nginx proxy_read_timeout |
60 s | Yes |
HAProxy timeout tunnel |
unset (falls back to timeout client) |
Yes |
| NAT gateway (Linux conntrack) | 120 s (UDP) / 432000 s (TCP ESTABLISHED) | Kernel param |
| Cloudflare (Enterprise) | 600 s | Yes |
Node.js keepAliveTimeout misconception Permalink to this section
server.keepAliveTimeout controls how long Node.js holds a keep-alive socket open after a response finishes so it can be reused for the next HTTP/1.1 request. It does not govern long-lived streaming endpoints β a streaming response never finishes until the client disconnects. Setting keepAliveTimeout alone does nothing for SSE idle drops; the correct lever is a periodic res.write() at the application layer.
headersTimeout interaction Permalink to this section
If server.headersTimeout (default 60 000 ms in Node 18+) is less than the time between heartbeat frames, Node will close the socket before a slow-starting client completes the handshake. Always set headersTimeout slightly above keepAliveTimeout.
Step-by-Step Resolution Permalink to this section
Step 1 β Emit correct SSE response headers and flush immediately Permalink to this section
Four headers are mandatory. flushHeaders() pushes them onto the wire before Nodeβs internal buffering kicks in, ensuring the client sees a 200 OK before the first heartbeat.
// handler.js
function sseHandler(req, res) {
res.writeHead(200, {
'Content-Type': 'text/event-stream', // required by SSE spec
'Cache-Control': 'no-cache', // prevent proxy / CDN caching
'Connection': 'keep-alive', // explicit HTTP/1.1 keep-alive
'X-Accel-Buffering': 'no', // disable Nginx proxy buffering
});
res.flushHeaders(); // send headers immediately, don't wait for first write
}
X-Accel-Buffering: no is an Nginx-specific header. Without it, Nginx buffers the entire response in memory and only forwards data to the client when the upstream closes β which defeats SSE entirely. For buffer management across other proxy types see the dedicated guide.
Step 2 β Start a heartbeat interval using SSE comment frames Permalink to this section
SSE comment lines begin with : and are silently ignored by the EventSource API. They do, however, push bytes through every layer of the network stack, resetting all idle timers.
// heartbeat every 20 s β shorter than any typical 60 s proxy timeout
const HEARTBEAT_MS = 20_000;
const heartbeat = setInterval(() => {
if (!res.writableEnded) {
res.write(': keep-alive\n\n'); // SSE comment; clients ignore this
}
}, HEARTBEAT_MS);
Choose your interval to be well under the shortest idle timeout in your stack. 15β25 seconds is safe for most deployments that include AWS ALB (60 s default) or Nginx (60 s default).
Step 3 β Tune Node.js server-level timeouts Permalink to this section
Override the three relevant timeouts at server creation time or immediately after. Apply these before the first server.listen() call.
const http = require('http');
const server = http.createServer(sseHandler);
// Keep the socket alive after response completion (non-streaming connections)
server.keepAliveTimeout = 300_000; // 5 min β must exceed your longest proxy idle timeout
// Must be > keepAliveTimeout to avoid Node closing before the client sends headers
server.headersTimeout = 305_000; // 5 min + 5 s buffer
// 0 = no request timeout; required for indefinite streaming connections
server.requestTimeout = 0;
server.listen(3000, () => console.log('SSE server on :3000'));
Step 4 β Clean up on client disconnect Permalink to this section
Bind to req.on('close') to cancel the heartbeat timer and release resources. Not doing this causes setInterval accumulation β one leaked interval per connected client β which eventually starves the event loop. See handling client disconnects in Node.js SSE for patterns covering graceful shutdown under load.
req.on('close', () => {
clearInterval(heartbeat); // stop the heartbeat timer
if (!res.writableEnded) res.end();
console.log(`Client disconnected; active intervals cleared`);
});
Step 5 β Complete production implementation Permalink to this section
Assemble all four steps into a single runnable server:
// sse-server.js
'use strict';
const http = require('http');
const HEARTBEAT_MS = 20_000;
const server = http.createServer((req, res) => {
if (req.url !== '/events') {
res.writeHead(404).end('Not Found');
return;
}
// Step 1: SSE headers + immediate flush
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
// Optional: tell the client to wait 5 s before retrying on disconnect
res.write('retry: 5000\n\n');
// Step 2: heartbeat comment every 20 s
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write(': keep-alive\n\n');
}, HEARTBEAT_MS);
// Step 3: send a real event (replace with your actual event source)
const dataTimer = setInterval(() => {
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({ ts: Date.now() })}\n\n`);
}
}, 60_000); // example: send real data every 60 s
// Step 4: teardown on disconnect
req.on('close', () => {
clearInterval(heartbeat);
clearInterval(dataTimer);
if (!res.writableEnded) res.end();
});
});
// Step 3: server-level timeout tuning
server.keepAliveTimeout = 300_000;
server.headersTimeout = 305_000;
server.requestTimeout = 0;
server.listen(3000, () => console.log('SSE server listening on :3000'));
Step 6 β Configure Nginx (if present) Permalink to this section
Application-level heartbeats are not sufficient if Nginx closes the proxy connection first. Add these directives to the upstream location block:
location /events {
proxy_pass http://node_backend;
proxy_http_version 1.1;
# Disable Nginx proxy buffering for streaming
proxy_buffering off;
proxy_cache off;
# Set to 0 to disable read timeout on streaming connections
proxy_read_timeout 3600s; # 1 hour; tune to match your session limits
# Ensure the SSE headers pass through
proxy_set_header Connection '';
proxy_set_header X-Accel-Buffering no;
}
For rate limiting and backpressure considerations when a large number of these connections run concurrently, consult the backpressure guide.
Validation & Monitoring Permalink to this section
Verify with curl Permalink to this section
# -N disables buffering; watch for ": keep-alive" lines at ~20 s intervals
curl -N -v --no-buffer http://localhost:3000/events 2>&1 | \
ts '[%Y-%m-%d %H:%M:%.S]' # pipe through 'ts' from moreutils to timestamp each line
Expected output (abbreviated):
[2026-06-21 10:00:00] < HTTP/1.1 200 OK
[2026-06-21 10:00:00] < Content-Type: text/event-stream
[2026-06-21 10:00:00] < Connection: keep-alive
[2026-06-21 10:00:00] < X-Accel-Buffering: no
[2026-06-21 10:00:00] retry: 5000
[2026-06-21 10:00:20] : keep-alive
[2026-06-21 10:00:40] : keep-alive
Inspect TCP socket state Permalink to this section
# On the server host β confirm ESTABLISHED state persists through idle windows
ss -tnp 'sport = :3000'
# Expected: TIME-WAIT count should be near-zero during active SSE sessions
# Each active client should show ESTABLISHED with non-zero Recv-Q / Send-Q drain
Unit test stub (Node.js + http module) Permalink to this section
// test/keepalive.test.js (plain Node, no framework)
const http = require('http');
const assert = require('assert/strict');
it('emits heartbeat within 25 s', (done) => {
const req = http.get('http://localhost:3000/events', (res) => {
assert.equal(res.headers['content-type'], 'text/event-stream');
let gotHeartbeat = false;
const timeout = setTimeout(() => {
assert(gotHeartbeat, 'expected a heartbeat comment within 25 s');
req.destroy();
done();
}, 25_000);
res.on('data', (chunk) => {
if (chunk.toString().includes(': keep-alive')) {
gotHeartbeat = true;
clearTimeout(timeout);
req.destroy();
done();
}
});
});
});
Browser DevTools signal Permalink to this section
Open Network β Events (Chrome) or Network β XHR (Firefox) for the /events request. Under EventStream, you should see blank comment entries every ~20 seconds. If the connection resets, you will see a gap in the timeline followed by a new request β that indicates the heartbeat interval is too long relative to a proxy timeout.
Metrics to alert on Permalink to this section
| Metric | Source | Alert threshold |
|---|---|---|
EventSource onerror rate |
browser metric | > 0.1 errors/client/min |
| SSE request duration p50 | APM / access log | < 5 min (indicates early drops) |
Node.js active handles (process._getActiveHandles()) |
custom endpoint | growing unboundedly |
req.socket.bytesWritten delta |
custom middleware | 0 for > 30 s on active stream |
β‘ Production Directives
- Set heartbeat interval to half the shortest proxy idle timeout in your stack (e.g., 20 s when the ALB default is 60 s).
- Always call
res.flushHeaders()immediately afterres.writeHead()β otherwise Nginx may buffer headers until the first data write. - Set
server.requestTimeout = 0on SSE endpoints; the Node.js default (0 in Node 18, but may change) can otherwise kill long-lived streams. - Always bind
req.on('close')and callclearInterval(); leaked timers under 1 000 concurrent connections consume measurable CPU. - Set Nginx
proxy_read_timeoutto at least 2Γ your heartbeat interval plus expected maximum event latency.
Verification Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Does setting Connection: keep-alive in the response header actually do anything in Node.js?
In HTTP/1.1 connections are keep-alive by default, so the header is technically redundant. It does serve as documentation and can be read by intermediate proxies that use it to decide whether to keep the upstream connection alive. It costs nothing to send and is conventional in SSE implementations, so include it. The real work is done by the heartbeat bytes and server-level timeout configuration.
Why use SSE comment lines instead of real events for the heartbeat?
SSE comment lines (lines starting with :) are explicitly defined in the WHATWG HTML spec as lines the client MUST ignore. They do not trigger onmessage, do not increment lastEventId, and do not affect the retry counter. They are the correct wire-level mechanism for keepalive frames and cause zero application-level side effects.
How do I handle keep-alive with Express.js instead of raw http.createServer?
Express wraps http.createServer; you access the server instance from app.listen()'s return value. Example: const server = app.listen(3000); server.keepAliveTimeout = 300_000; server.headersTimeout = 305_000;. The request handler approach is identical β use res.setHeader(), res.flushHeaders(), and setInterval exactly as shown above.
What if I'm running behind AWS ALB with a 60-second idle timeout I can't change?
Set your heartbeat interval to 25 seconds or less. ALB resets its idle timer on any byte transmitted β a heartbeat comment satisfies this. If you cannot change the ALB idle timeout (e.g., shared account policy), 15 s heartbeats provide comfortable headroom. Alternatively, request a limit increase: ALB supports idle timeouts up to 4 000 seconds via the idle_timeout.timeout_seconds attribute.
Will heartbeat comments affect the retry: reconnection field?
No. The retry: field is only parsed from lines that literally start with retry:. Comment lines (starting with :) are discarded by the parser before any field extraction. Heartbeat comments are fully transparent to the client's reconnection logic and to lastEventId tracking. See the guide on event ID and retry mechanism design for details on how reconnection state is maintained.