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.
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.
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);
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.
Related #
- Reconciling Snapshots and Deltas over WebSockets — the client-side reconciliation protocol.
- Permessage-Deflate Compression Trade-Offs — compression versus deltas.
- Protocol Buffers over WebSockets — compact encoding of each delta.
- Coalescing High-Frequency WebSocket Updates — fewer deltas per second.
Back to Message Framing & Serialization.