Server-Sent Event (SSE) connections silently terminate after 60–120 seconds of payload inactivity. Frontend EventSource instances fire onerror callbacks, triggering aggressive reconnect loops that spike CPU, exhaust connection pools, and generate false-positive alerts.
Intent: Configure the Node.js HTTP server to emit periodic heartbeat comments (: heartbeat\n\n) to maintain TCP socket activity. This bypasses idle timeouts enforced by reverse proxies, load balancers, and NAT gateways without disrupting the event stream.
The text/event-stream MIME type does not inherently prevent idle connection drops. Infrastructure layers (Nginx, HAProxy, AWS ALB, Cloudflare) enforce strict proxy_read_timeout or idle connection limits. When zero bytes traverse the socket within the configured threshold, the proxy issues a TCP RST or FIN.
Node.js http.Server defaults to a 5-second keepAliveTimeout, but this parameter only governs standard HTTP/1.1 request-response cycles, not persistent streaming endpoints. Without explicit application-level keep-alive frames, intermediate network layers treat the idle stream as dead. Mapping proxy idle thresholds to persistent stream behavior is detailed in the HTTP Keep-Alive & Connection Lifecycle documentation.
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // Critical for Nginx
});
res.flushHeaders();
: prefix) every 15–30 seconds. The SSE spec ignores lines starting with :, so clients do not trigger onmessage handlers.const HEARTBEAT_INTERVAL_MS = 20000;
const heartbeat = setInterval(() => {
res.write(': keep-alive\n\n');
}, HEARTBEAT_INTERVAL_MS);
server.keepAliveTimeout = 300000; // 5 minutes
server.headersTimeout = 305000; // Slightly higher than keepAlive
server.requestTimeout = 0; // Disable for long-lived streams
req.on('close') to clear intervals and release memory. Failure to do so causes setInterval accumulation and memory leaks under high concurrency.req.on('close', () => {
clearInterval(heartbeat);
res.end();
});
Complete Runnable Implementation
const http = require('http');
const server = http.createServer((req, res) => {
if (req.url !== '/events') return res.writeHead(404).end();
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
res.flushHeaders();
const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), 20000);
req.on('close', () => {
clearInterval(heartbeat);
res.end();
});
});
server.keepAliveTimeout = 300000;
server.headersTimeout = 305000;
server.requestTimeout = 0;
server.listen(3000, () => console.log('SSE server listening on :3000'));
Local Verification
Run curl -N -v http://localhost:3000/events. Verify : keep-alive payloads appear at exact 20s intervals. Confirm no Connection: close or Transfer-Encoding: chunked anomalies interrupt the stream.
Proxy Bypass & TCP State Test
Route traffic through your production load balancer. Monitor socket persistence with ss -tnp | grep :3000. Validate that ESTABLISHED state survives >5 minutes of zero data payload. Drop rate should be 0% during idle windows.
Client-Side Telemetry
Instrument EventSource.readyState transitions. A stable 1 (CONNECTED) confirms successful keep-alive. Track onerror frequency; a drop to near-zero validates proxy timeout resolution. Log lastEventId to ensure reconnection resumes from the correct offset.
Resource Guardrails
Monitor heap usage via process.memoryUsage().heapUsed. Ensure clearInterval executes reliably; accumulation under 1k concurrent connections should not exceed baseline + 50MB. Alert on req.socket.bytesRead === 0 persisting beyond 30s to catch zombie connections before OOM.