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.

Send buffer for a 400 msg/s stream to a 250 msg/s client Buffered bytes over 20 seconds when 400 messages per second are offered to a socket draining 250 per second: unbounded growth versus a policy that caps the queue at the high-water mark. Send buffer for a 400 msg/s stream to a 250 msg/s client 400 msg/s offered, 250 msg/s drained, 180 B each No backpressure Watermark policy 0 MB 0.5 MB 1 MB 1.5 MB 0s 5s 10s 15s 20s
Without coalescing, a client that drains slower than the source grows its queue linearly — and every queued price is already stale.

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.

Frames per second to one client Forwarding sends one frame per update so frames scale with symbols times rate; coalescing sends ten frames per second regardless of symbol count. Frames per second to one client 400 updates/s per symbol, 100 ms flush forwarded coalesced (frames/s) 0 20k 40k 60k 80k 1 symbol 20 symbols 200 symbols
Coalescing decouples the client's frame rate from the source's update rate entirely.

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.

One 100 ms flush window Within one 100 millisecond window three AAPL updates and one MSFT update arrive, and the flush sends only the latest AAPL value and the MSFT value. One 100 ms flush window flush: send batch (0ms) AAPL 191.02 (12ms) MSFT 402.10 (31ms) AAPL 191.04 (replaces) (47ms) AAPL 191.01 (replaces) (78ms) flush: 2 keys sent (100ms) Four source updates became one frame with two keys, and the client sees the newest price
Superseded values never leave the server.

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.

Back to WebSocket Backpressure & Flow Control.