Delta encoding WebSocket state updates #

Your live dashboard pushes the full state of a 400-row table every second, 180 KB per message, to every viewer. Most seconds, three cells changed. Bandwidth bills climb, mobile clients fall behind, and the browser spends its frame budget parsing data it already has. The obvious fix is to send only the differences — deltas — and apply them on the client. Done well, deltas cut traffic by one or two orders of magnitude. Done carelessly, they create a new class of bugs: a lost or reordered delta silently corrupts the client’s copy, nothing ever repairs it, and two users stare at different numbers for the same row. Delta encoding is a protocol decision, and it needs versions, a recovery path and a rule for when a full snapshot is cheaper.

Root cause #

A full snapshot is self-contained: whatever arrives replaces what the client had, so loss, duplication and reordering are harmless — the next snapshot fixes everything. A delta is only meaningful relative to a base: “row 17’s price changed to 191.04” assumes the client has row 17. If the client missed the delta that inserted row 17, or applied an older delta after a newer one, the result is wrong, and because later deltas also assume the correct base, the error persists.

So deltas trade bandwidth for a correctness obligation: the client must know which version each delta applies to, detect when its base does not match, and fall back to a full snapshot. That is the same snapshot-plus-delta reconciliation problem described in reconciling snapshots and deltas over WebSockets, applied to the encoding of every update.

Illustrative message size: snapshot vs delta When three or forty cells change, a delta is a fraction of a kilobyte to a few kilobytes against a 180-kilobyte snapshot, but when three hundred rows are reordered the delta grows to 210 kilobytes, larger than the snapshot. Illustrative message size: snapshot vs delta 400-row table, per update full snapshot (KB) delta (KB) 0 KB 100 KB 200 KB 300 KB 180 KB 3 cells changed 180 KB 40 cells changed 180 KB 210 KB 300 rows reordered
Deltas win massively for small changes — and lose when most of the state changes at once.

Resolution #

Version every state stream, send deltas that name the version they apply to, and let the server choose per update between a delta and a full snapshot (a keyframe), based on size. The client applies a delta only if its current version equals the delta’s base version, and requests a keyframe otherwise.

// Shared: a keyed collection of rows; deltas are field-level patches per row.
type Row = Record<string, string | number | null>;
type State = Map<string, Row>;
type Msg =
| { kind: 'keyframe'; version: number; rows: [string, Row][] }
| { kind: 'delta'; base: number; version: number; upserts: [string, Partial<Row>][]; deletes: string[] };

// ---------------- Server: diff, choose delta or keyframe ----------------
const KEYFRAME_EVERY = 300; // periodic keyframe bounds error lifetime and late-join cost
const DELTA_MAX_RATIO = 0.5; // if the delta is > 50% of a keyframe, send a keyframe

export function encodeUpdate(prev: State, next: State, prevVersion: number): Msg {
const version = prevVersion + 1;
const upserts: [string, Partial<Row>][] = [];
const deletes: string[] = [];
for (const [id, row] of next) {
const old = prev.get(id);
if (!old) { upserts.push([id, row]); continue; }
const changed: Partial<Row> = {};
for (const k of Object.keys(row)) if (row[k] !== old[k]) changed[k] = row[k];
if (Object.keys(changed).length) upserts.push([id, changed]); // only changed fields
}
for (const id of prev.keys()) if (!next.has(id)) deletes.push(id);

const delta: Msg = { kind: 'delta', base: prevVersion, version, upserts, deletes };
const keyframe: Msg = { kind: 'keyframe', version, rows: [...next] };
const tooBig = JSON.stringify(delta).length > DELTA_MAX_RATIO * JSON.stringify(keyframe).length;
return tooBig || version % KEYFRAME_EVERY === 0 ? keyframe : delta;
}

// ---------------- Client: apply only on matching base ----------------
export class DeltaState {
private state: State = new Map();
private version = -1; // -1 = nothing yet: must start from a keyframe

constructor(private requestKeyframe: () => void) {}

apply(msg: Msg) {
if (msg.kind === 'keyframe') {
this.state = new Map(msg.rows);
this.version = msg.version;
return;
}
if (msg.version <= this.version) return; // duplicate or stale: ignore
if (msg.base !== this.version) { // wrong base: a delta was missed
this.requestKeyframe();
return;
}
for (const id of msg.deletes) this.state.delete(id);
for (const [id, patch] of msg.upserts) this.state.set(id, { ...(this.state.get(id) ?? {}), ...patch });
this.version = msg.version;
}

get rows() { return this.state; }
}

The base-version check is the whole safety story: a missed or reordered delta can never be applied to the wrong state, because its base will not match. The periodic keyframe bounds how long any undetected divergence could last and gives late joiners a starting point without a separate request. For deltas to be cheap to compute, the server must keep the previous state it sent — per stream, not per client, since every client on a stream receives the same sequence; clients that fall behind simply request a keyframe.

Field-level diffs, as above, suit keyed collections like tables and dashboards. For arbitrary nested documents, JSON Patch (RFC 6902) is a standard alternative, with operations such as replace /rows/17/price; libraries generate and apply patches, though index-based paths into arrays break under concurrent insertions, so prefer keyed maps over arrays in state that is patched. For superseding values updated many times a second, combine deltas with coalescing high-frequency WebSocket updates so each flush carries at most one change per field.

A missed delta, detected and repaired The client applies a keyframe at version 100 and a delta to 101; the delta to 102 is lost; the next delta has base 102, which does not match the client's version 101, so the client rejects it, requests a keyframe and receives version 104. A missed delta, detected and repaired Server Client keyframe v100 delta base 100 → v101 delta base 101 → v102 (lost) delta base 102 → v103 base 102 ≠ 101: reject request keyframe keyframe v104 Without the base check, v103 would have been applied to v101 and the error would persist
Every delta names its base; a mismatch costs one keyframe, never a silent error.

Edge cases #

Compression overlap. permessage-deflate already exploits repetition between consecutive messages when context takeover is enabled, which recovers some of the gain of deltas automatically. Measure delta size after compression, not before, and consider whether compression alone is enough — see permessage-deflate compression trade-offs.

Per-client views. If clients see different subsets (filters, permissions), per-stream deltas no longer apply to every client. Either partition streams by view, or compute deltas per client — which moves the cost from bandwidth to server CPU and memory.

Floating-point noise. Values recomputed each tick may differ in the last digit without meaningful change, defeating the diff. Round to display precision before diffing.

Verification #

Test correctness with a replay harness: generate a random sequence of states, encode it, then deliver the messages with random loss, duplication and reordering to a client, answering its keyframe requests. After the run, the client’s state must equal the server’s final state, and every mismatch must have triggered a keyframe request. Then measure the payoff on real traffic: bytes per second per client before and after, compressed, alongside keyframe request rates.

// Divergence check in development: every Nth version, the server also sends a checksum.
import { createHash } from 'node:crypto';
export const checksum = (s: State) =>
createHash('sha1').update(JSON.stringify([...s].sort(([a], [b]) => a.localeCompare(b)))).digest('hex').slice(0, 12);
Choosing an update encoding Full snapshots suit small or mostly-changing state; field-level deltas suit keyed rows with few changed fields but need base versions; JSON Patch suits nested documents but array index paths are fragile; coalesced values suit high-rate superseding fields but not events. Choosing an update encoding Best when Watch out for Full snapshots small state or most of it changes bandwidth at scale Field-level deltas keyed rows, few fields change base versions required JSON Patch nested documents array index paths Coalesced values superseding high-rate fields not for events Most dashboards end up with keyframes plus field-level deltas
Deltas are an optimization with a correctness contract attached.

Operational checklist #

FAQ #

Should I send deltas or full state over WebSockets? #

Deltas when state is large and changes are small, with versions and keyframes for recovery. Full state when state is small, changes touch most of it, or you cannot afford the correctness machinery.

What happens if a delta is lost? #

Without versioning, the client’s state silently diverges. With base versions, the next delta’s base will not match the client’s version, the client rejects it and requests a keyframe, and correctness is restored.

Is JSON Patch a good format for WebSocket deltas? #

It is standard and well supported, and it works well for nested documents. Avoid patching arrays by index when items can be inserted or removed concurrently; key collections by id instead.

How often should I send keyframes? #

Often enough to bound the damage of an undetected error and to serve late joiners cheaply — every few hundred deltas or every few minutes is common — and whenever a delta would be more than about half the size of a keyframe.

Back to Message Framing & Serialization.