Versioning Message Schemas Across Updates

During an MV3 update the worker, popup and content scripts run different builds. Version the message protocol so old senders and new receivers keep understanding each other.

Published August 1, 2026 Updated August 1, 2026 8 min read
Table of Contents

You renamed a message field from tabId to targetTabId, shipped it, and the feature broke for everyone with a tab open — the content script from the previous build is still sending the old name to a worker that no longer reads it. Inside one extension you have accidentally built a distributed system with independent deployment, and message payloads are its wire protocol. This page is part of Extension Updates & Data Migration and covers versioning that protocol so a mixed-version window is boring instead of a bug report.

Root cause: the surfaces update at different moments

The service worker is replaced the instant the update applies. The popup picks up the new build the next time it opens. Options pages already open keep their old code until reloaded. Content scripts in existing tabs never update until navigation or re-injection. For a period measured in hours on a real user’s machine, several versions of your code are talking to each other.

When each surface picks up a new buildThe worker updates immediately, the popup on next open, an options tab on reload, and content scripts only on navigation.update appliedhours laterWorker: …immediate…Popup: next openseconds to minutesOptions tab: on reloadwhenever the user reloadsContent scripts: on navigationcan be days for a pinned tabnew receiver, old sendersthe mixed-version window
The rightmost band is where a protocol change without versioning shows up as a support ticket.

Step 1. Put a version on every message

One integer, added at the boundary rather than by hand at each call site.

 1// protocol.js — imported by every surface
 2export const PROTOCOL_VERSION = 3;
 3
 4export function envelope(type, payload) {
 5  return { v: PROTOCOL_VERSION, type, payload };
 6}
 7
 8export function parse(message) {
 9  // A message with no version predates versioning: treat it as v1, not as invalid.
10  const v = typeof message?.v === "number" ? message.v : 1;
11  return { v, type: message?.type, payload: message?.payload ?? message };
12}

Execution context: A shared module imported by the service worker, popup, options page and content script alike — the same file must ship to all of them or the constant drifts. The fallback to version 1 for an unversioned message is the part that pays for itself: the first release that adds versioning still has to accept messages from the build that did not have it. parse also tolerates a bare payload, so old code that sent { type, tabId } without an envelope still yields something usable.

Step 2. Upgrade old payloads at the receiver

The receiver is always the newest code in the pair — it was updated first. Make it responsible for translating.

 1// protocol.js
 2const UPGRADES = {
 3  1: (p) => ({ ...p, targetTabId: p.tabId, tabId: undefined }),        // v1 → v2: field rename
 4  2: (p) => ({ ...p, filters: p.filter ? [p.filter] : [] }),           // v2 → v3: scalar → array
 5};
 6
 7export function upgrade(v, payload) {
 8  let current = payload;
 9  for (let from = v; from < PROTOCOL_VERSION; from++) {
10    const step = UPGRADES[from];
11    if (!step) throw new Error(`no-upgrade-path-from-v${from}`);
12    current = step(current);
13  }
14  return current;
15}

Execution context: Service worker or any receiving surface. Chaining single-step upgrades means adding version 4 requires writing one function rather than editing three. Throwing on a missing path is deliberate — a message from a version you no longer support should fail loudly at the boundary rather than half-work deep inside a handler. Because this is plain data transformation, it is identical in all engines and trivially unit-testable without a Chrome double.

Step 3. Refuse messages from the future, politely

An old surface can also receive from a new one — a content script that has not been re-injected will get responses and broadcasts from the new worker.

The two directions of version skewAn old sender's message is upgraded by the new receiver; a new sender's message reaching an old receiver must be rejected with a reload hint.Old senderv1 content scriptNew receiverv3 workerupgrade(1 → 3)handled silentlythe reverse direction has no upgrade pathNew senderv3 workerOld receiverv1 content scriptReject + retireask for a reload
Only one direction can be fixed by code — the other has to ask for a reload.
 1// content.js
 2chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
 3  const { v, type, payload } = parse(message);
 4
 5  if (v > PROTOCOL_VERSION) {
 6    // The extension updated under us. This document cannot speak the new protocol.
 7    sendResponse({ ok: false, code: "stale-context", have: PROTOCOL_VERSION, want: v });
 8    retire();
 9    return false;
10  }
11
12  handle(type, upgrade(v, payload)).then(sendResponse);
13  return true;
14});

Execution context: Content script, isolated world. Answering before retiring gives the worker a usable diagnostic rather than a silent timeout, and retire() is the cleanup routine from fixing extension context invalidated after an update. Returning false on the synchronous branch and true on the async one is the MV3 contract in Chrome and Safari; Firefox additionally accepts a returned Promise, but using the callback form everywhere keeps one code path.

Step 4. Keep the worker’s response shape stable

The protocol version covers payloads. The envelope itself — how success and failure are signalled — should never change, because every version of every surface depends on it.

One message through the frozen envelopeThe receiver parses the version, upgrades the payload, runs the handler and answers with the same ok-shaped envelope whatever happened.Old senderparse()upgrade()handler{ type, tabId } — no version fieldtreated as v1v1 → v2 → v3 payload{ ok: true, data }or { ok: false, code: "unsupported-version" }
Every reply has the same shape, so a v1 sender can still tell success from failure.
 1// sw.js
 2chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
 3  const { v, type, payload } = parse(message);
 4
 5  let upgraded;
 6  try {
 7    upgraded = upgrade(v, payload);
 8  } catch (error) {
 9    sendResponse({ ok: false, code: "unsupported-version", detail: String(error) });
10    return false;
11  }
12
13  handle(type, upgraded, sender).then(
14    (data) => sendResponse({ ok: true, data }),
15    (error) => sendResponse({ ok: false, code: "handler-failed", detail: String(error) }),
16  );
17  return true;
18});

Execution context: Service worker, top-level registration so a cold start answers the first message. Every reply is { ok, ... } regardless of protocol version, so a v1 content script can still tell success from failure even when it cannot understand the data. Treat that envelope as frozen: changing it is the one migration that has no upgrade path, because both sides must already agree on it to negotiate anything else.

Step 5. Retire old versions on a schedule you choose

Upgrade steps accumulate. Removing them is safe only once no installed surface can still be sending that version — and a content script in a pinned tab that has not navigated for a month is the straggler that decides when that is.

 1// sw.js — count what is still out there before deleting an upgrade step
 2chrome.runtime.onMessage.addListener((message) => {
 3  const { v } = parse(message);
 4  if (v < PROTOCOL_VERSION) {
 5    chrome.storage.session.get("skew").then(({ skew = {} }) => {
 6      skew[v] = (skew[v] ?? 0) + 1;
 7      chrome.storage.session.set({ skew });
 8    });
 9  }
10  return false;
11});

Execution context: Service worker; a separate listener that observes and never responds, so it cannot interfere with the handler above — listeners are additive in all three engines. Keeping the tally in chrome.storage.session means it resets each browser session, which is what you want: a session with no v1 messages is evidence that this user has no stale contexts left. Two or three releases of clean sessions is a reasonable bar before dropping an upgrade step.

Cross-browser variation

  • Chrome/Edge: updates apply on idle, so a user with pinned tabs can sit in the mixed-version window for a long time. sendResponse must be called synchronously or the listener must return true.
  • Firefox: updates apply more eagerly, shortening the window but making it more frequent. Listeners may return a Promise instead of using sendResponse, but mixing both styles in one listener silently drops replies.
  • Safari: updates arrive with the containing app, usually at launch, so most surfaces restart together and skew is rare. The sendResponse contract matches Chrome’s.
  • All engines: long-lived ports opened before an update are severed rather than downgraded, so port-based features need the same version handshake in their onConnect payload — see long-lived ports vs one-time messages.

Verification

  1. Load version A, open a matched page, then load version B with a bumped PROTOCOL_VERSION without reloading the tab. The old content script’s messages should still work through the upgrade path.
  2. Send a message from the new worker to the old content script. It should reply stale-context and clean itself up rather than throwing.
  3. Delete an upgrade step and replay a v1 message. The worker should answer unsupported-version, not crash.
  4. Inspect chrome.storage.session.get("skew") after normal use. On a freshly reloaded browser it should be empty.

FAQ

Is a version number really needed for an extension?

It is needed as soon as two surfaces can run different builds, which is always in MV3. The cost is one integer per message and one shared module.

Should the protocol version track the manifest version?

No. Most releases do not change the protocol, and tying them together forces an upgrade step for every bug-fix release.

What about messages between extensions?

chrome.runtime.onMessageExternal needs this more, not less — the other extension updates on a schedule you do not control. The same envelope works, as covered in cross-extension and native messaging.

Can I just reload every tab after an update instead?

You can, and it destroys unsaved form state in pages the user was working in. Versioning is a few dozen lines; a forced reload is a support ticket.

Other MV3 Architecture & Extension Lifecycle Resources