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.

How version skew arises between a page and its worker Three scenarios producing mismatched builds. A service worker serves a cached document from build seven while the worker chunk is fetched fresh from build eight. A tab left open since build six creates a new worker today and receives build eight. A content delivery network mid-propagation serves the page from one edge running build eight and the worker chunk from another still running build seven. In all three the page and worker disagree, and without negotiation the symptom is an ignored message or an undefined field rather than an error. Three ordinary ways the two sides end up on different builds cached document page: build 7 (service worker) worker chunk: build 8 (network) the most common case, and the hardest to reproduce locally long-lived tab page: build 6, opened Monday worker: created today, build 8 lazy worker creation makes this more likely, not less mid-propagation CDN page: edge A, build 8 worker: edge B, build 7 minutes-long window after every release Without negotiation A renamed field reads as undefined, a renamed kind is silently ignored, and the feature half-works until the tab is closed. No error is raised anywhere.
None of the three is a mistake anyone made — which is why the protocol, rather than the deployment, has to absorb it.

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.

The handshake and its three outcomes A handshake sequence with three branches. The page sends a hello listing the versions it supports along with its build identifier. If the ranges overlap, the worker replies with the highest mutually supported version and normal traffic proceeds at that version. If the page is newer than anything the worker supports, the worker replies with a version mismatch naming its supported range, and the page reloads to pick up a matching build. If the page is older than the worker's minimum, the same mismatch reply is sent and the page reloads. A note records that no request is dispatched before the handshake completes. Negotiate once, before any real traffic page → HELLO supports [2, 3], build 8 worker compares its own range overlap → agreed v3 traffic proceeds no overlap → mismatch page reloads to realign Requests are queued until HELLO_OK arrives, so no message can be interpreted under the wrong version — the queue drains in order once the version is agreed. The reload branch is deliberate: a mismatch is a deployment state, and reloading resolves it in one step — far better than degrading quietly for the rest of the session.
One extra round trip at startup, once per worker, buys a protocol that cannot be silently misread.

Retiring an Old Version

Removing support is the step teams skip, and then the compatibility window becomes permanent. A workable schedule:

  1. Ship the new version alongside the old. Both are accepted; the worker answers at whichever was negotiated. One release.
  2. 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.
  3. 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.
  4. Raise MIN_SUPPORTED_VERSION. Old callers now get a mismatch and reload, which is correct because by this point they are genuinely stale.
  5. 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.


Retiring an old protocol version without breaking live tabs Four steps. Ship the new version alongside the old so both are accepted. Emit telemetry whenever the old version is negotiated, including the build identifier. Wait until that number reaches zero for longer than the longest realistic tab lifetime. Then raise the minimum supported version and delete the compatibility code in a commit that does nothing else. The retirement schedule ship both old and new accepted one release measure usage telemetry on the old path with build ids wait it out longer than a tab lives days, or weeks raise the floor then delete the code in its own commit Without the second step the schedule is guesswork, and the compatibility window becomes permanent. Each step is cheap; skipping one is what makes the next expensive.
Most teams do the first step and never the fourth, which is how three shapes accumulate per message.

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.

Frequently Asked Questions

The page and the worker ship together — how can their versions ever differ?
Three ways, all common. A service worker can serve a cached page from an earlier deploy while the worker chunk is fetched fresh, or the reverse. A long-lived tab keeps running last week’s page while a newly created worker loads the current chunk. And a partially propagated CDN can serve assets from two builds for several minutes after a release. Version skew is a deployment property, not a bundling mistake.
Should every message carry a version field?
Put the version in the handshake and, cheaply, on the envelope. Negotiating once at connection time keeps per-message overhead at a single small integer and gives both sides a chance to refuse or downgrade before any real traffic. Per-message versioning matters only when messages can outlive a connection — for example when they are persisted or replayed.

See also