Snapshot Plus Delta Streaming for Dashboards Permalink to this section
Part of Live Dashboards & Metrics Feeds, under Real-Time Application Patterns.
Sending only what changed is the obvious way to keep a dashboard stream small. Sending only what changed, and nothing else, is the reason so many dashboards show wrong numbers after a reconnect. This guide builds the protocol that fixes it: a versioned snapshot at the start of every connection, deltas that name the version they apply to, and a client that detects the one case where it must ask for help.
Symptom & Developer Intent Permalink to this section
Delta-only dashboards fail in recognisable ways:
- After Wi-Fi drops and returns, some tiles show values from before the drop until that metric happens to change again — sometimes for hours on a quiet metric.
- Two people looking at the same dashboard see different numbers for the same tile.
- A tile that was removed on the server stays on the client forever, because “removed” was never a delta.
- A newly opened tab shows empty tiles until each metric changes for the first time.
- Occasionally a value jumps backwards: an old delta arrived after a newer one following a server-side reorder.
The intent is a stream where every client, at any moment, holds state equal to some version the server actually had — never a mixture of two.
Root Cause Analysis Permalink to this section
A delta is only meaningful relative to the state it was computed from. {"rps": 1302} means “change rps to 1302 starting from version 1842”. A client that missed versions 1843 to 1850 and applies version 1851’s delta ends up with a state that never existed on the server: some fields current, others eight versions old.
EventSource makes the gap window real. When the connection drops, events published during the retry delay are simply never sent to that client. Without a snapshot on reconnect, and without a version check on each delta, the client has no way to know it is wrong. The browser’s Last-Event-ID resend gives the server the information it needs — the client’s last version — but the server must use it.
Deletions are the second root cause. A delta format of “fields and their new values” cannot express “this field no longer exists”. A tile for a decommissioned host keeps its last value until the page reloads.
Step-by-Step Resolution Permalink to this section
Step 1 — Version the whole view, not individual fields Permalink to this section
Keep a monotonically increasing version for the view. Every applied change increments it once, however many fields changed.
// view-store.js — the single source of truth on each SSE node.
export class ViewStore {
constructor() { this.version = 0; this.values = new Map(); }
apply(changes, removed = []) {
const set = {};
for (const [k, v] of Object.entries(changes)) {
if (this.values.get(k) !== v) { this.values.set(k, v); set[k] = v; }
}
const del = removed.filter((k) => this.values.delete(k));
if (!Object.keys(set).length && !del.length) return null; // nothing changed, no version bump
const base = this.version;
this.version += 1;
return { base, version: this.version, set, del };
}
snapshot() { return { version: this.version, values: Object.fromEntries(this.values) }; }
}
Step 2 — Make deltas carry their base version and deletions Permalink to this section
The delta names the version it was computed from (base) and the version it produces. Deletions travel explicitly.
event: snapshot
id: 1842
data: {"version":1842,"values":{"cpu":38.2,"rps":1290,"host-7":"up"}}
event: delta
id: 1843
data: {"base":1842,"set":{"rps":1302},"del":[]}
event: delta
id: 1844
data: {"base":1843,"set":{},"del":["host-7"]}
Putting the version in id: as well means the browser echoes it back as Last-Event-ID on reconnect with no extra client code.
Step 3 — Decide on the server whether a reconnect needs a snapshot Permalink to this section
app.get('/dash/stream', (req, res) => {
openStream(res);
const clientVersion = Number(req.get('Last-Event-ID') ?? -1);
const snap = store.snapshot();
// A client exactly at the current version needs nothing; anyone else gets a snapshot.
if (clientVersion !== snap.version) {
res.write(`event: snapshot\nid: ${snap.version}\ndata: ${JSON.stringify(snap)}\n\n`);
}
const onDelta = (d) => {
res.write(`event: delta\nid: ${d.version}\ndata: ${JSON.stringify(d)}\n\n`);
};
bus.on('delta', onDelta);
req.on('close', () => bus.off('delta', onDelta));
});
Take the snapshot and subscribe to deltas without yielding in between. In Node.js, both lines run in the same synchronous turn, so no delta can slip between them. In a multi-threaded server, take a lock or read the snapshot and the subscription position atomically; otherwise a delta applied between the two is either lost or applied twice.
Step 4 — Apply deltas only when the base matches Permalink to this section
// client reducer: apply in order, or detect the gap and resync.
export function applyFrame(state, type, frame, resync) {
if (type === 'snapshot') {
return { version: frame.version, values: { ...frame.values } };
}
if (!state || frame.base !== state.version) {
resync(); // never apply a delta to the wrong base
return state;
}
const values = { ...state.values, ...frame.set };
for (const k of frame.del) delete values[k];
return { version: frame.version, values };
}
The resync callback closes and reopens the EventSource. Because the last successfully applied version is still the Last-Event-ID, the server sees a stale version and sends a snapshot. In practice gaps are rare once step 3 is in place — they indicate a server bug or a message reordered by a broker — so treating them as “start again” is both simple and safe.
Step 5 — Keep snapshots cheap for large views Permalink to this section
A view with thousands of tiles makes the snapshot the most expensive frame. Two techniques keep it cheap. Serialise the snapshot once per version and share the string across all connections that need it, instead of calling JSON.stringify per connection. And for very large views, let the client request a partial snapshot — only the tiles on screen — by passing the tile set as a query parameter when it opens the stream.
let cached = { version: -1, text: '' };
function snapshotFrame() {
if (cached.version !== store.version) {
const snap = store.snapshot();
cached = { version: snap.version, text: `event: snapshot\nid: ${snap.version}\ndata: ${JSON.stringify(snap)}\n\n` };
}
return cached.text; // one serialisation per version, however many reconnects
}
Validation & Monitoring Permalink to this section
Test the reconnect path explicitly, because it is the path that is broken in delta-only designs.
# 1. Open the stream, record the last id you saw.
curl -sN https://app.example.com/dash/stream | grep -m3 '^id:'
# 2. Reconnect claiming an old version: expect a snapshot as the first event.
curl -sN -H 'Last-Event-ID: 1' https://app.example.com/dash/stream | head -3
# 3. Reconnect claiming the current version: expect no snapshot, only deltas.
curl -sN -H "Last-Event-ID: $(curl -s https://app.example.com/dash/version)" \
https://app.example.com/dash/stream | head -3
A unit test for the reducer should cover four cases: snapshot replaces everything, matching delta applies, deletion removes a key, and mismatched base triggers resync without changing state.
In production, count snapshots sent per reason — new_connection, stale_reconnect, client_resync — as a labelled counter. client_resync should be near zero; a rising rate means deltas are being reordered or dropped between the store and the socket, often by a broker that does not preserve order across partitions.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why put the version in the SSE id field?
Because the browser automatically sends the last id back in the Last-Event-ID header when it reconnects. Using the version as the id gives the server the client's exact position with no custom client code.
Can deltas be sent without a base version?
Only if the transport can never drop or reorder them, which SSE across reconnects cannot guarantee. Without a base the client cannot detect that it has missed something, so it silently shows a state that never existed.
How big can a snapshot be?
There is no protocol limit, but proxies and client parsers have practical ones and large frames delay everything behind them. Keep snapshots under a few hundred kilobytes, and for larger views send only the tiles the client is displaying.
What about multiple SSE nodes with different versions?
Versions must come from one source — a single writer or the broker's sequence — so that version 1843 means the same state on every node. If each node counts independently, a client that reconnects to a different node compares incompatible numbers.