Handling Client Disconnects in Node.js SSE Permalink to this section
Part of Node.js Streaming Architecture Basics.
When you open a long-lived SSE endpoint in Node.js and the browser tab closes, a mobile device suspends the app, or a proxy drops an idle connection, the server process keeps running β burning CPU on setInterval ticks, holding Redis subscriptions open, and accumulating res references that can never be flushed. Left unchecked, each leaked connection consumes a file descriptor, a socket buffer, and whatever per-client state your handler allocated. On a busy server this compounds into out-of-memory crashes and exhausted descriptor limits within hours.
This guide covers the exact sequence: detecting the disconnect event, tearing down every resource tied to that request, and validating the cleanup under real-world conditions.
Symptom & Developer Intent Permalink to this section
The most common symptoms you will observe:
- Node.js process memory climbs monotonically over hours despite stable request rates.
setInterval/setTimeoutcallbacks throwError [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sentor silently write into a dead socket.- Redis subscriber count keeps growing β
CLIENT LISTshows subscriptions far exceeding active browser sessions. - File-descriptor count approaches the OS limit β
lsof -p <pid> | wc -loutput rises without bound. - In logs:
write EPIPE,write ECONNRESET, orsocket hang uperrors that originate inside a timer callback, not from an HTTP handler.
The developer intent is straightforward: run cleanup logic exactly once, as soon as the client is gone, no matter how the connection ended (graceful browser close, network drop, proxy timeout, or explicit EventSource.close() call).
Root Cause Analysis Permalink to this section
Why Node.js doesnβt auto-clean SSE handlers Permalink to this section
HTTP/1.1 SSE streams work by keeping the TCP connection open and writing chunks continuously. Unlike a normal JSON response, res.end() is never called by the handler β it stays pending. Node.jsβs http.Server does fire a close event on the req socket when the TCP FIN arrives, but the handler function has already returned. Nothing inside async function handler(req, res) can intercept a future socket event unless you explicitly attached a listener before returning.
The req close event Permalink to this section
Node.js (and frameworks that wrap it: Express, Fastify, Koa) expose the 'close' event on the req object (an IncomingMessage). It fires once the underlying socket is destroyed β covering:
| Termination cause | req emits 'close' |
req emits 'aborted' (Node < 19) |
|---|---|---|
| Browser tab closed (graceful FIN) | Yes | No |
| Network loss / proxy timeout | Yes | Sometimes |
EventSource.close() called |
Yes | No |
| Client HTTP/2 RST_STREAM | Yes | No |
Server calls res.destroy() |
Yes | No |
req.on('aborted', ...) was the old pattern (Node.js < 16) but has been deprecated since Node 19. Use req.on('close', ...) unconditionally β it fires in all of the above cases and is the only event you need.
Why cleanup must be idempotent Permalink to this section
A naΓ―ve implementation calls clearInterval(timer) inside the close handler. That is safe for a single timer. But if your handler also holds a Redis pub/sub channel, a database cursor, and a reference in a Set<Response> broadcast registry, you must clean all of them β and guard against double-invocation (some proxies send a RST followed by a FIN).
Step-by-Step Resolution Permalink to this section
Step 1 β Attach the close listener before any async work Permalink to this section
The listener must be registered synchronously inside the handler, before any await, because the client could disconnect during an initial auth query.
// server.js β Express 4 / Node 18+
import express from 'express';
const app = express();
app.get('/events', (req, res) => {
// 1. Set SSE headers first
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // flush before any async work
// 2. Register cleanup BEFORE the first await / timer
req.on('close', () => cleanup(req, res));
// 3. Now start streaming
const timer = setInterval(() => {
if (res.writableEnded) return; // belt-and-suspenders guard
res.write(`data: ${Date.now()}\n\n`);
}, 1000);
// Store timer so cleanup can reach it
req._sseTimer = timer;
});
function cleanup(req, res) {
clearInterval(req._sseTimer);
// res is already closed from the client side; do NOT call res.end() here β
// it will throw ERR_HTTP_HEADERS_SENT if the socket is already destroyed.
console.log('client disconnected, cleaned up timer');
}
app.listen(3000);
Step 2 β Use a per-connection cleanup object Permalink to this section
Attaching state to req works for simple cases but becomes messy. A cleanup closure scoped to the handler is cleaner and easier to test.
app.get('/events', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
// Collect teardown functions
const teardowns = [];
const onClose = () => {
teardowns.forEach(fn => { try { fn(); } catch (_) {} });
teardowns.length = 0; // prevent double-run
};
req.on('close', onClose);
// Heartbeat timer β keeps proxies from killing idle connections
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write(': heartbeat\n\n');
}, 15_000);
teardowns.push(() => clearInterval(heartbeat));
// Business-logic timer
const ticker = setInterval(() => {
if (!res.writableEnded) res.write(`data: tick\n\n`);
}, 2_000);
teardowns.push(() => clearInterval(ticker));
});
Every resource that gets created pushes exactly one teardown function. Adding a new resource later cannot cause a leak because the pattern is consistent.
Step 3 β Clean up Redis pub/sub subscriptions Permalink to this section
Redis pub/sub fan-out for SSE is one of the most common sources of leaked connections. Each client subscribes to a channel; when the client leaves, the Redis subscriber must unsubscribe and (if it is a dedicated connection) be released back to the pool.
import { createClient } from 'redis';
// Shared publisher; per-connection subscriber (Redis pub/sub requires a
// dedicated connection per subscriber β do not reuse the publisher).
const publisher = createClient({ url: process.env.REDIS_URL });
await publisher.connect();
app.get('/events/:channel', async (req, res) => {
const { channel } = req.params;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const teardowns = [];
req.on('close', () => {
teardowns.forEach(fn => { try { fn(); } catch (_) {} });
teardowns.length = 0;
});
// Dedicated subscriber connection
const subscriber = publisher.duplicate();
await subscriber.connect();
teardowns.push(async () => {
await subscriber.unsubscribe(channel);
await subscriber.quit();
});
await subscriber.subscribe(channel, (message) => {
if (!res.writableEnded) {
res.write(`data: ${message}\n\n`);
}
});
// Heartbeat
const hb = setInterval(() => {
if (!res.writableEnded) res.write(': ping\n\n');
}, 20_000);
teardowns.push(() => clearInterval(hb));
});
Note that teardowns can hold async teardown functions. If you mix sync and async teardowns, run them with await Promise.all(teardowns.map(fn => fn())) instead of a forEach.
Step 4 β Remove the client from a broadcast registry Permalink to this section
When you maintain a Set or Map of active res objects for fan-out broadcasting (without Redis), failing to delete the entry causes the broadcaster to accumulate dead write targets and eventually throw EPIPE on every broadcast cycle.
// broadcast-registry.js
export const clients = new Set();
export function broadcast(data) {
for (const res of clients) {
if (res.writableEnded) {
clients.delete(res); // lazy cleanup fallback
continue;
}
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
}
// In the SSE handler
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
clients.add(res);
req.on('close', () => {
clients.delete(res); // primary cleanup
});
});
The clients.delete(res) inside broadcast is a fallback β you should not rely on it as the primary path because it fires one broadcast cycle too late.
Step 5 β Handle Fastify Permalink to this section
Fastifyβs Reply object wraps http.ServerResponse. The underlying req.raw exposes the Node.js IncomingMessage. Use request.raw.on('close', ...) or the Fastify onRequestAbort hook (Fastify 4.5+):
// Fastify 4 β using the lifecycle hook
fastify.addHook('onRequestAbort', async (request) => {
const timer = request.sseTimer; // stored during handler
if (timer) clearInterval(timer);
});
fastify.get('/events', async (request, reply) => {
reply.raw.setHeader('Content-Type', 'text/event-stream');
reply.raw.setHeader('Cache-Control', 'no-cache');
reply.raw.flushHeaders();
const timer = setInterval(() => {
if (!reply.raw.writableEnded) reply.raw.write(`data: tick\n\n`);
}, 1_000);
request.sseTimer = timer; // accessible in the hook
// Keep the handler alive
await new Promise(resolve => request.raw.on('close', resolve));
});
Validation & Monitoring Permalink to this section
Verify with curl and process introspection Permalink to this section
# 1. Start the server
node server.js &
SERVER_PID=$!
# 2. Open a connection and capture its PID-level FD count before disconnect
curl -sN http://localhost:3000/events &
CURL_PID=$!
sleep 1
echo "FDs open: $(lsof -p $SERVER_PID | wc -l)"
# 3. Kill the client and recheck FD count β should drop by 1 (the socket)
kill $CURL_PID
sleep 1
echo "FDs after disconnect: $(lsof -p $SERVER_PID | wc -l)"
If the FD count does not drop, the socket is being held open β check that req.on('close', ...) is firing by adding a console.log inside the handler.
Unit-test stub with node:http mock Permalink to this section
// disconnect.test.js β Node built-in test runner
import { test } from 'node:test';
import assert from 'node:assert/strict';
import EventEmitter from 'node:events';
test('cleanup fires on req close', async (t) => {
let cleanedUp = false;
// Minimal mock of req / res
const req = new EventEmitter();
const res = {
writableEnded: false,
setHeader() {},
flushHeaders() {},
write() {},
};
// Simulate the handler attaching the listener
const timer = setInterval(() => {}, 1000);
const teardowns = [() => { clearInterval(timer); cleanedUp = true; }];
req.on('close', () => teardowns.forEach(fn => fn()));
// Fire the close event (simulates browser tab closing)
req.emit('close');
assert.equal(cleanedUp, true, 'teardown must run on close');
});
Metrics to monitor in production Permalink to this section
Expose these via your metrics layer (Prometheus, Datadog, etc.):
| Metric | How to collect | Healthy signal |
|---|---|---|
sse_active_connections |
Increment on flushHeaders, decrement on close |
Stable over time; drops match client leaves |
sse_cleanup_errors_total |
Counter inside teardown catch blocks |
Zero |
process_open_fds |
/proc/<pid>/fd or prom-client |
Does not grow linearly with uptime |
redis_connected_clients |
INFO clients |
Tracks sse_active_connections |
See connection pooling for SSE servers for how to size your pool against this metric.
Verification Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Should I call res.end() inside the close handler?
No. When req emits close, the underlying socket is already gone. Calling res.end() will either silently no-op or throw ERR_HTTP_HEADERS_SENT. Only call res.end() when you are ending the stream intentionally (e.g., a scheduled shutdown). Let the socket destruction happen on its own.
Is req.on('aborted') still needed for older Node versions?
req.on('aborted', ...) was documented for connections cut before the request body was fully received. For SSE β where the request body is empty and the response is the long-lived stream β 'close' covers all cases on Node 12+. The 'aborted' event was formally deprecated in Node 17 and removed in Node 22. Use 'close' exclusively.
What happens if the cleanup function throws?
An unhandled throw inside a 'close' event listener will propagate as an unhandled exception and can crash the process in Node 15+. Always wrap individual teardown steps in try/catch and log the error. The pattern shown in Step 2 β iterating teardowns with individual try/catch around each β ensures one failing teardown does not block the others.
How do I detect disconnects in HTTP/2 multiplexed SSE?
HTTP/2 SSE uses a stream RST rather than a TCP FIN. Node.js's http2 module maps this to a 'close' event on the Http2ServerRequest, so the same req.on('close', ...) pattern works. Verify with curl --http2 -sN http://localhost:3000/events. The HTTP Keep-Alive & Connection Lifecycle guide covers the negotiation differences between HTTP/1.1 and HTTP/2 for SSE.
Can I use AbortController to signal downstream work on disconnect?
Yes β and it composes well with async teardowns. Create an AbortController per connection, call controller.abort() in the close handler, and pass controller.signal to any fetch or database call spawned for that client. This automatically cancels in-flight async work without needing to track each promise individually.
β‘ Production Directives
- Register
req.on('close', ...)synchronously as the very first side-effect in every SSE handler β before anyawait. - Push every per-connection resource (timers, Redis subscriptions, registry entries) into a teardown array; iterate it atomically on close.
- Instrument
sse_active_connectionsas a gauge that increments on stream open and decrements oncloseβ a rising baseline under stable traffic is a leak signal. - Set
ulimit -nhigh enough for your peak connection count plus headroom, and alert whenprocess_open_fdsexceeds 80% of the limit. - Validate cleanup with a churn test: 200 concurrent curl clients that each disconnect after 5 s β FD count must return to baseline within 10 s.