Designing Versioned Message Protocols
The reassuring assumption is that the page and its worker are built from the same commit. Deployment breaks it routinely: a cached document, a long-lived tab, a half-propagated CDN. When the shapes disagree, the failure is a message that is silently ignored or a field read as undefined — never an error that names the cause.
This guide extends Message Passing Strategies, part of Web Workers Architecture & Communication, with the compatibility rules that make a protocol survive its own deploys.
The Envelope
Version the envelope, keep the payload free to evolve, and negotiate once:
// protocol.ts — imported by both sides, the single source of truth
export const PROTOCOL_VERSION = 3;
export const MIN_SUPPORTED_VERSION = 2; // what this build can still talk to
export interface Envelope<T = unknown> {
v: number; // protocol version of the SENDER
id: number; // correlation id
kind: string; // message type
payload: T;
}
export type Hello = Envelope<{ supports: number[]; build: string }>;
// worker.ts — negotiate before serving anything
let agreed = 0;
self.addEventListener('message', (event: MessageEvent<Envelope>) => {
const message = event.data;
if (message.kind === 'HELLO') {
const theirs = (message.payload as { supports: number[] }).supports;
agreed = Math.max(...theirs.filter((v) => v >= MIN_SUPPORTED_VERSION && v <= PROTOCOL_VERSION), 0);
self.postMessage({ v: PROTOCOL_VERSION, id: message.id, kind: 'HELLO_OK', payload: { agreed } });
return;
}
if (!agreed) {
// A caller that skipped the handshake, or one we cannot talk to.
self.postMessage({ v: PROTOCOL_VERSION, id: message.id, kind: 'ERROR',
payload: { code: 'VERSION_MISMATCH', need: [MIN_SUPPORTED_VERSION, PROTOCOL_VERSION] } });
return;
}
handle(message, agreed);
});
The page treats a VERSION_MISMATCH as a signal to reload — the worker is from a different build than the page, and a reload gets both from the same one. That is a far better outcome than a feature that misbehaves quietly for the rest of the session.
Changes That Are Safe, and Changes That Are Not
The distinction is the same as for any wire protocol, and it is worth writing down where the types live.
Safe (no version bump needed): adding an optional field; adding a new kind that old senders never send; widening an accepted value set; making a required response field’s contents more precise without changing its type.
Breaking (bump and negotiate): renaming or removing a field; changing a field’s type, including number-to-string; changing units (milliseconds to seconds is the classic silent disaster); making an optional field required; changing the meaning of an existing kind; reordering positional array payloads.
Two rules make most changes land in the first category. Add, never rename — ship the new field alongside the old, populate both for one release, then remove the old one in a later release when no live build reads it. And never change units or semantics in place: introduce timeoutMs next to timeout rather than redefining what timeout means, because there is no way for a reader to detect the difference.
Tolerant Readers
Both sides should read defensively, in a way that makes forward compatibility the default:
// Ignore what you do not understand; never throw on an unknown kind.
function handle(message: Envelope, version: number): void {
switch (message.kind) {
case 'PARSE': return onParse(message as Envelope<ParsePayload>, version);
case 'CANCEL': return onCancel(message as Envelope<{ target: number }>);
default:
// A newer sender may know kinds this build does not. Reply, do not crash.
self.postMessage({ v: PROTOCOL_VERSION, id: message.id, kind: 'UNSUPPORTED',
payload: { kind: message.kind } });
}
}
// Read fields with defaults rather than assuming presence.
function onParse(message: Envelope<ParsePayload>, version: number): void {
const { text, delimiter = ',', header = true } = message.payload;
// A field introduced in v3; older senders simply do not set it.
const trimQuotes = version >= 3 ? (message.payload.trimQuotes ?? false) : false;
…
}
The UNSUPPORTED reply matters more than it looks. Without it, a message the worker does not recognise produces silence, and the caller’s promise never settles — the failure is a hang rather than an error. Answering every message, even to say “I do not know what this is”, makes every caller’s timeout unnecessary for this class of problem.
Retiring an Old Version
Removing support is the step teams skip, and then the compatibility window becomes permanent. A workable schedule:
- Ship the new version alongside the old. Both are accepted; the worker answers at whichever was negotiated. One release.
- Emit telemetry when the old version is negotiated, including the page’s build identifier. That number is what tells you whether anyone is still on it.
- Wait for the number to reach zero for longer than your longest realistic tab lifetime. For most applications that is days; for a dashboard people leave open, weeks.
- Raise
MIN_SUPPORTED_VERSION. Old callers now get a mismatch and reload, which is correct because by this point they are genuinely stale. - Delete the compatibility code. Only now, and in a commit that does nothing else, so it is easy to revert if the telemetry was misleading.
The telemetry in step 2 is what makes the schedule empirical rather than superstitious. Without it, teams either remove too early — and break the long-lived tabs that mattered — or never remove at all and carry three shapes for every message indefinitely.
Making the Contract Enforceable
A versioning scheme that lives in prose gets violated within two sprints. Three cheap mechanisms make it structural instead.
One module owns every message type, and it is imported by both sides. A field renamed there breaks compilation on both threads simultaneously, which is the earliest possible feedback. Types split across two files diverge silently, and the drift only appears at runtime — the argument made in Typed RPC Contracts with Comlink applies to hand-written protocols just as strongly.
A snapshot test over the message shapes. Serialise one example of every message kind and commit the result. A change to any shape then shows up as a diff in review, where the question “is this additive or breaking?” can actually be asked:
it('message shapes are stable', () => {
expect(exampleMessages()).toMatchSnapshot(); // reviewers see every shape change
});
A test that runs the current worker against the previous release’s messages. Keep a fixture of the last version’s serialised requests and assert the current worker still answers them. This is the only mechanism that catches a breaking change nobody realised was breaking — a narrowed enum, a field that stopped being optional — and it costs one small fixture file per supported version.
Together these turn compatibility from a matter of discipline into something CI can fail on, which is what makes the retirement schedule below safe to follow rather than aspirational.
Gotchas
Versioning the payload but not the envelope. If the envelope’s own shape changes — a renamed id field, a moved kind — the receiving side cannot even parse the message well enough to discover the version. The envelope must be the most stable thing in the protocol.
Bumping the version for additive changes. Every bump costs a compatibility branch and a retirement cycle. Adding an optional field needs neither, so reserve version numbers for changes that genuinely break a reader — otherwise the supported range grows faster than it can be retired, and the negotiation logic becomes the most complicated part of the protocol.
A handshake that is not idempotent. A worker restarted by a supervisor will handshake again on the same logical connection. If the second HELLO resets state the page assumed persisted, the recovery path introduces bugs of its own — treat re-negotiation as normal rather than exceptional.
Assuming structured clone rejects unknown fields. It does not: extra fields arrive intact and are simply ignored by a reader that does not look for them. That is what makes additive changes safe, and it also means a typo in a field name fails silently.
Letting the build identifier stand in for the protocol version. A commit hash changes on every deploy, including deploys that do not touch the protocol, so using it as the compatibility key forces a reload for every release. Version the protocol independently and let it change rarely.
Negotiating per request instead of per connection. A version check on every message is overhead for no benefit; the pair cannot change build mid-connection.
Forgetting that a pool has many connections. Each worker in a pool negotiates separately. During a rolling replacement, some members may run the old build and some the new — so the agreed version is per worker, not per pool.
Performance Note
The handshake costs one round trip — 0.2–1 ms — once per worker, and the v field adds about 8 bytes per message, which is under 0.01% of a typical payload and does not measurably change structured-clone time. Against that: a single silent unit change shipped without versioning, in a protocol carrying timeouts, produced values 1,000× wrong for every user on a cached build until the next deploy. The asymmetry is the whole argument.