Coalescing high-frequency WebSocket updates #
A market-data feed produces 400 price changes a second for a symbol your users are watching. You forward every one of them, and three things go wrong at once: mobile clients fall behind and their bufferedAmount climbs until the server disconnects them, browser tabs spend their frame budget parsing JSON for prices nobody can read that fast, and the egress bill triples. The data is not the problem — the delivery granularity is. For any stream where a newer value supersedes an older one, sending every intermediate value is waste. Coalescing keeps only the latest value per key and flushes on a fixed cadence, which bounds the outbound rate per client regardless of how fast the source produces.
Root cause #
Many real-time streams are state streams rather than event streams. A stock price, a cursor position, a progress percentage, the number of people in a room: each message says “the value is now X”, and a later message makes every earlier one obsolete. Delivering every message is only necessary for event streams, where each item carries distinct meaning — a chat message, an order fill, an audit entry.
When a state stream is forwarded one-to-one, the outbound message rate equals the source rate, which the client cannot control. Past the rate the client’s network and main thread can absorb, messages queue: first in the server’s per-socket send buffer, then in the kernel, then in your heap — the mechanism explained in handling WebSocket bufferedAmount backpressure. Every queued message is also stale by the time it arrives, so the user sees a price that is seconds old while the correct one sits at the back of the queue.
Resolution #
The coalescer below sits between your source and each socket. Updates are written into a per-client map keyed by entity (symbol, userId, docId), so a newer value for the same key simply overwrites the pending one. A timer flushes the map every FLUSH_INTERVAL_MS as a single batched frame. Event-type messages bypass the map and are sent immediately, so coalescing never drops meaningful events.
import { WebSocket } from 'ws';
const FLUSH_INTERVAL_MS = 100; // at most 10 frames/s per client
const MAX_KEYS_PER_FLUSH = 500; // bound the size of any single batch
const SKIP_FLUSH_ABOVE_BYTES = 256 * 1024; // client already backlogged: wait
type StateUpdate = { key: string; value: unknown; ts: number };
export class Coalescer {
private pending = new Map<string, StateUpdate>();
private timer: ReturnType<typeof setInterval>;
private dropped = 0; // superseded updates, for metrics
constructor(private ws: WebSocket) {
this.timer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
ws.once('close', () => clearInterval(this.timer));
}
// State: newest value wins. Map order is insertion order, so a key that
// updates keeps its original slot — deletion first moves it to the end.
pushState(update: StateUpdate) {
if (this.pending.has(update.key)) {
this.dropped += 1;
this.pending.delete(update.key);
}
this.pending.set(update.key, update);
}
// Events are never coalesced: each one is distinct and must arrive.
pushEvent(event: object) {
this.ws.send(JSON.stringify({ t: 'event', e: event }));
}
private flush() {
if (this.pending.size === 0 || this.ws.readyState !== WebSocket.OPEN) return;
// A backlogged client keeps accumulating in the map instead of the socket —
// the map is bounded by the number of keys, the socket buffer is not.
if (this.ws.bufferedAmount > SKIP_FLUSH_ABOVE_BYTES) return;
const batch: StateUpdate[] = [];
for (const [key, update] of this.pending) {
batch.push(update);
this.pending.delete(key);
if (batch.length >= MAX_KEYS_PER_FLUSH) break; // remainder goes next tick
}
this.ws.send(JSON.stringify({ t: 'state', u: batch }));
}
stats() { return { pendingKeys: this.pending.size, superseded: this.dropped }; }
}
The crucial property is that memory per client is now bounded by the number of distinct keys, not by the source rate or the duration of a slowdown. A client subscribed to 50 symbols holds at most 50 pending entries no matter how far behind it falls. Skipping the flush while bufferedAmount is high turns the map into the queue, and a map that overwrites is a queue that cannot grow.
On the client, apply a batch as one state transition, not as N separate ones. In a React or Vue app this means a single store update per frame rather than per key — the rendering side of the same idea is in batching WebSocket updates with requestAnimationFrame.
Choosing the interval is a latency trade. A 100 ms flush adds at most 100 ms of delay to any update, averages 50 ms, and is below the threshold at which people perceive a number as lagging. Cursor positions in collaborative tools tolerate 50 ms; dashboards tolerate 500 ms or more. Pick per stream, and give clients a way to request a slower cadence when they know they are on a constrained network.
Verification #
Measure three numbers before and after enabling coalescing: outbound frames per second per client, outbound bytes per second, and end-to-end staleness (server timestamp to client render time). Frames and bytes should drop sharply; staleness should drop too, counterintuitively, because nothing waits in a queue behind obsolete values.
# Outbound frame rate per client from the server's metrics endpoint (Prometheus text format).
curl -s localhost:9464/metrics | grep -E '^ws_frames_sent_total|^ws_coalesce_superseded_total'
A useful health indicator is the superseded ratio: superseded / (superseded + sent keys). A high ratio means coalescing is doing a lot of work, which is expected for fast feeds. A ratio near zero on a stream you thought was fast means the source is slower than the flush interval, and you can lengthen the interval without adding perceptible delay.
Operational checklist #
FAQ #
Won’t coalescing lose data? #
It discards only values that a newer value for the same key has already replaced, which is exactly what the client would do on receipt anyway. It must never be applied to event streams, where each message is distinct; route those around the coalescer.
How is this different from rate limiting? #
Rate limiting drops or rejects messages beyond a rate, and the ones it drops may be the newest. Coalescing always keeps the newest value per key and drops only the obsolete ones, so the client’s view converges on the current state even at a fraction of the source rate. See rate limiting WebSocket messages per client for inbound limits.
Should I coalesce on the server or the client? #
On the server, because that is where the bandwidth and the send buffer are. Client-side batching is still useful for rendering, but it cannot reduce the bytes that already crossed the network.
Does coalescing work with delta updates? #
Only if deltas can be merged. Replacing values per key assumes each update is a full value for that key. If you send deltas (+3, -1), merge them into the pending entry instead of replacing it, or switch to sending absolute values for coalesced streams.
Related #
- Handling WebSocket bufferedAmount Backpressure — the watermark this coalescer respects.
- Disconnecting Slow WebSocket Consumers — the last resort when even coalesced traffic backs up.
- Batching WebSocket Updates with requestAnimationFrame — the client-side counterpart.
- Fixing Slow-Consumer Memory Growth — what happens without any of this.