Disconnecting slow WebSocket consumers #
One client on a congested hotel Wi-Fi subscribes to a busy channel. It reads at a fraction of the rate you publish, its outbound queue on the server grows by megabytes a minute, and eventually the Node process pauses for garbage collection long enough that every other client on the node sees a latency spike. Watermarks and coalescing slow the growth, but some consumers will never catch up — and keeping them connected is worse for them and for everyone else than disconnecting them. This page defines a policy for when to give up on a slow consumer, how to close so the client recovers correctly, and how to stop the same client from immediately repeating the cycle.
Root cause #
A server that publishes faster than a client reads has only three choices for each outbound message: queue it, drop it, or stop producing for that client. Queuing without bound is the default in ws and every other WebSocket library — send() never refuses — so memory grows until the process falls over. Dropping silently corrupts state for event streams. Pausing production is ideal for request/response flows but impossible for a broadcast channel whose other subscribers need the data now.
For broadcast workloads the honest answer is a fourth choice: evict the consumer and let it resynchronize. A client that is 30 seconds behind on a live feed is not helped by receiving 30 seconds of stale updates in order; it is helped by dropping them and loading a fresh snapshot. Eviction converts an unbounded, fleet-harming queue into one reconnect and one snapshot, which is cheaper for the server and gives the user current data sooner.
Resolution #
Track two signals per socket: bytes buffered (bufferedAmount) and the age of the oldest unsent message. Bytes protect memory; age protects correctness, because a small buffer of very old messages is just as useless to the user as a large one. The policy below evicts on either signal, after a grace period so a momentary stall does not trigger it.
import { WebSocket } from 'ws';
const SOFT_BUFFER_BYTES = 512 * 1024; // start degrading: skip non-critical streams
const HARD_BUFFER_BYTES = 4 * 1024 * 1024; // evict immediately above this
const MAX_LAG_MS = 15_000; // oldest undelivered message may be this old
const GRACE_MS = 5_000; // continuous lag before evicting on age
const CLOSE_TRY_AGAIN_LATER = 1013;
const CHECK_INTERVAL_MS = 1_000;
interface Consumer {
ws: WebSocket;
outbox: { enqueuedAt: number; payload: string; critical: boolean }[];
laggingSince: number | null;
}
export function send(c: Consumer, payload: string, critical = false) {
if (c.ws.bufferedAmount > SOFT_BUFFER_BYTES && !critical) {
metrics.skippedNonCritical.inc(); // degrade: shed optional traffic first
return;
}
c.outbox.push({ enqueuedAt: Date.now(), payload, critical });
drain(c);
}
function drain(c: Consumer) {
while (c.outbox.length && c.ws.bufferedAmount < SOFT_BUFFER_BYTES) {
c.ws.send(c.outbox.shift()!.payload);
}
}
export function checkConsumer(c: Consumer, now = Date.now()) {
drain(c);
const buffered = c.ws.bufferedAmount;
const oldest = c.outbox[0]?.enqueuedAt;
const lagMs = oldest ? now - oldest : 0;
if (buffered > HARD_BUFFER_BYTES) return evict(c, 'buffer_hard_limit');
if (lagMs > MAX_LAG_MS) {
c.laggingSince ??= now;
if (now - c.laggingSince > GRACE_MS) return evict(c, 'lag_age');
} else {
c.laggingSince = null; // recovered: reset the grace clock
}
}
function evict(c: Consumer, reason: string) {
metrics.slowConsumerEvictions.inc({ reason });
c.outbox.length = 0; // free the queue now, not after close completes
// 1013 = "try again later": the client should reconnect with backoff and resync.
c.ws.close(CLOSE_TRY_AGAIN_LATER, 'slow consumer');
// If the close frame itself cannot be flushed, do not wait for it.
setTimeout(() => c.ws.terminate(), 2_000).unref();
}
// setInterval(() => consumers.forEach((c) => checkConsumer(c)), CHECK_INTERVAL_MS);
The close code matters. 1013 Try Again Later tells a well-behaved client that the server is fine but the connection should be retried after a delay, and — paired with a reason string — that the client should discard its local stream position and resync from a snapshot rather than attempting to resume. Resuming would ask the server to replay exactly the backlog that caused the eviction. The client-side resync is covered in reconciling snapshots and deltas over WebSockets.
To stop the same client immediately repeating the cycle, remember recent evictions by client identifier and offer a degraded subscription on reconnect: coalesced updates only, or a lower update rate. A client evicted three times in ten minutes is on a network that cannot carry the full stream, and serving it a lighter one is a better experience than a reconnect loop — see coalescing high-frequency WebSocket updates.
Verification #
Simulate a slow reader rather than waiting for one. A test client can pause its socket so the kernel stops acknowledging data:
// Test client: connect, subscribe to a busy channel, then stop reading.
import WebSocket from 'ws';
const ws = new WebSocket('ws://localhost:8080/?channel=firehose');
ws.on('open', () => {
// Pausing the underlying socket stops reads; the server's buffer will grow.
(ws as any)._socket.pause();
});
ws.on('close', (code, reason) => console.log('closed', code, String(reason)));
// Expect: "closed 1013 slow consumer" after MAX_LAG_MS + GRACE_MS or at the hard limit.
On the server, graph slow_consumer_evictions_total by reason alongside process heap. Eviction counts should be a tiny fraction of connections; if they are large, your publish rate or message size is wrong for your audience, and eviction is hiding a design problem. The heap graph should stop showing sawtooth growth that correlates with individual clients.
Operational checklist #
FAQ #
Isn’t disconnecting users a bad experience? #
Less bad than the alternative. A client that is 30 seconds behind shows stale data and, if left alone, eventually degrades the whole node. An eviction followed by a snapshot shows current data within a second or two, and a subtle “reconnecting” indicator is enough for the user to understand.
Which close code should I use for a slow consumer? #
1013 Try Again Later fits best: it signals a temporary server-side condition and tells clients to retry after a delay. 1008 Policy Violation is sometimes used, but many clients treat it as permanent and stop reconnecting.
How do I tell a slow client from a dead one? #
A dead client stops acknowledging entirely; its buffer grows and never drains, and it also stops answering pings. A slow one drains, just not fast enough. The half-open detection sweep catches the first; this policy catches the second.
Should limits be per message size or per byte? #
Per byte for memory protection, because one large message can matter more than a hundred small ones. Use message age, not message count, for the correctness limit.
Related #
- Handling WebSocket bufferedAmount Backpressure — watermarks before eviction.
- Fixing Slow-Consumer Memory Growth — diagnosing the heap growth this prevents.
- Coalescing High-Frequency WebSocket Updates — the degraded mode to offer returning clients.
- WebSocket Close Codes Explained — why 1013 rather than 1008.