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.
Table of Contents
- Root cause: the surfaces update at different moments
- Step 1. Put a version on every message
- Step 2. Upgrade old payloads at the receiver
- Step 3. Refuse messages from the future, politely
- Step 4. Keep the worker’s response shape stable
- Step 5. Retire old versions on a schedule you choose
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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.
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.
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.
sendResponsemust be called synchronously or the listener must returntrue. - 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
sendResponsecontract 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
onConnectpayload — see long-lived ports vs one-time messages.
Verification
- Load version A, open a matched page, then load version B with a bumped
PROTOCOL_VERSIONwithout reloading the tab. The old content script’s messages should still work through the upgrade path. - Send a message from the new worker to the old content script. It should reply
stale-contextand clean itself up rather than throwing. - Delete an upgrade step and replay a v1 message. The worker should answer
unsupported-version, not crash. - 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.
Related
- Fixing extension context invalidated after an update — retiring the surfaces this protocol has to tolerate.
- Running data migrations on onInstalled — versioning the store as well as the wire.
- Long-lived ports vs one-time messages — where the handshake belongs for port-based features.
- Extension Updates & Data Migration — the guide this page belongs to.