Extension Updates & Data Migration

Ship an update without breaking installed users: onInstalled reasons, versioned storage migrations, orphaned content scripts, and controlling when the new version takes over.

An extension update is not a deploy. There is no maintenance window, no coordinated restart, and no guarantee that the code writing your data yesterday resembles the code reading it today — the browser swaps the service worker under a running browser while content scripts from the previous version are still attached to open tabs. Get this wrong and the symptom is not a crash on your machine; it is a support queue full of users whose settings vanished after an auto-update they never noticed. This guide is part of Manifest V3 Architecture & Extension Lifecycle and covers the update lifecycle end to end: detecting it, migrating data through it, and surviving the tabs that did not get the message.

What happens during an auto-updateThe browser downloads the new version, tears down the old worker, starts the new one with onInstalled, and leaves previously injected content scripts orphaned in open tabs.update detectedsteady stateDownload and verifybrowser-managedOld worke…listeners …onInstalled firesreason: updateMigrations runbefore the first event is…New scripts inject on next navigationold tabs still orphanedopen tabs keep the old content scriptstorage schema must be current here
The gap in the middle is the whole problem — old content scripts outlive the worker that spoke to them.

Prerequisites checklist

  • A version number that means something. manifest.json’s version is the only signal the browser gives you about what the user came from, and it is available only as details.previousVersion.
  • A schema version stored alongside your data. The manifest version tells you the code changed; it does not tell you what shape the data on disk is in. Keep an explicit schemaVersion key.
  • Top-level listener registration. chrome.runtime.onInstalled must be registered during script evaluation, not inside an await, or the event fires before you are listening.
  • A known set of storage keys. Migrating data you cannot enumerate is guesswork; the storage schema migration guide covers the versioned-key pattern this relies on.
  • A plan for open tabs. Decide up front whether orphaned content scripts should silently stop, reload themselves, or prompt the user.

1. Declare the keys the update path depends on

Nothing about updates is declared in the manifest except the version itself — but two adjacent keys change how updates behave, and both are easy to set wrong.

 1{
 2  "manifest_version": 3,
 3  "name": "Tab Curator",
 4  "version": "3.2.0",              // the only thing details.previousVersion compares against
 5  "minimum_chrome_version": "116", // users below this simply never receive the update
 6  "background": { "service_worker": "sw.js", "type": "module" },
 7  "content_scripts": [{
 8    "matches": ["https://*/*"],
 9    "js": ["content.js"],
10    "run_at": "document_idle"
11  }],
12  "permissions": ["storage", "scripting", "tabs"]
13}

Execution context: Parsed by the extension host at install and update time. version must be one to four dot-separated integers — 3.2.0-beta is rejected at upload, which is why extensions use version_name for a display string. Setting minimum_chrome_version freezes users below it on their current build permanently; Firefox uses browser_specific_settings.gecko.strict_min_version for the same purpose, and Safari inherits its floor from the containing app’s deployment target.

2. Branch on the install reason, not on presence of data

chrome.runtime.onInstalled fires for four distinct reasons and treating them alike is how first-run onboarding gets shown to a five-year user after a routine update.

 1// sw.js — registered at the top level, before any await
 2chrome.runtime.onInstalled.addListener(async (details) => {
 3  switch (details.reason) {
 4    case "install":
 5      await seedDefaults();
 6      await chrome.tabs.create({ url: "/welcome.html" });
 7      break;
 8    case "update":
 9      await migrateFrom(details.previousVersion);
10      break;
11    case "chrome_update":
12    case "shared_module_update":
13      // The browser changed, not us. Re-assert anything the browser may have dropped.
14      await reregisterDynamicScripts();
15      break;
16  }
17});

Execution context: Service worker, on the event loop turn immediately after the new version’s script is evaluated. details.previousVersion is present only for update. Firefox supports the same four reasons; Safari fires install and update reliably but chrome_update is not meaningful there, so any recovery work you attach to it needs a second home — running reregisterDynamicScripts from onStartup as well is the usual answer.

3. Make migrations ordered, idempotent and forward-only

Version-string comparison is where migration code goes wrong. Compare a monotonic integer you control instead, and run each step exactly once.

 1// migrations.js
 2export const MIGRATIONS = [
 3  { to: 1, run: async (s) => ({ ...s, rules: s.rules ?? [] }) },
 4  { to: 2, run: async (s) => ({ ...s, rules: s.rules.map(normaliseRule) }) },
 5  { to: 3, run: async (s) => { const { legacyToken, ...rest } = s; return rest; } },
 6];
 7
 8export async function migrate() {
 9  const { schemaVersion = 0, ...state } = await chrome.storage.local.get(null);
10  let current = state;
11
12  for (const step of MIGRATIONS) {
13    if (step.to <= schemaVersion) continue;
14    current = await step.run(current);
15    // Commit after every step so a crash resumes rather than restarts.
16    await chrome.storage.local.set({ ...current, schemaVersion: step.to });
17  }
18}

Execution context: Service worker, awaited from the onInstalled handler before any other work. Committing the schema version after each step is what makes the sequence crash-safe: a worker killed between steps resumes at the next one instead of re-running a transform that is not idempotent. Reading with get(null) pulls the whole area, which is fine for the kilobytes a settings store holds and wrong for a cache of thousands of entries — migrate those key by key, as migrating storage schema between versions sets out.

4. Gate every reader behind the migration

An update fires onInstalled and, at nearly the same moment, real events start arriving. A message handler that reads storage while the migration is halfway through sees a schema that belongs to neither version.

Serialising the first events behind the migrationA promise created during script evaluation is awaited by every handler, so no reader observes a partially migrated store.Worker evalready promiseonMessagestorageconst ready = migrate()await ready before readingsteps commit in orderresolves once, then instantlylater calls are freeread current schema
One promise, awaited everywhere — the cheapest correct answer to a race that is otherwise very hard to reproduce.
 1// sw.js
 2const ready = migrate().catch((error) => {
 3  console.error("migration failed", error);
 4  chrome.action.setBadgeText({ text: "!" });
 5  throw error;
 6});
 7
 8chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
 9  ready.then(() => handle(msg)).then(sendResponse, (e) => sendResponse({ error: String(e) }));
10  return true;
11});

Execution context: Service worker. Creating ready during script evaluation means every cold start — not only the one after an update — passes through the same gate, and the migration is a no-op once schemaVersion is current. Because the worker is rebuilt from scratch on each wake-up, this costs one storage read per cold start in all three engines. Attaching the failure to a badge gives the user a visible signal instead of a silently broken extension.

5. Deal with the tabs that never reloaded

After an update, every open tab still runs the previous version’s content script, and its chrome.runtime connection to the extension is dead. Calling into it throws Extension context invalidated.

 1// content.js — injected into pages, must detect its own orphaning
 2function isOrphaned() {
 3  try { return !chrome.runtime?.id; } catch { return true; }
 4}
 5
 6const port = chrome.runtime.connect({ name: "content" });
 7port.onDisconnect.addListener(() => {
 8  // The worker went away. That is normal on eviction and terminal after an update.
 9  if (isOrphaned()) teardown();          // remove listeners, observers and injected DOM
10  else scheduleReconnect();
11});

Execution context: Content script, in the isolated world of the page. chrome.runtime.id becomes undefined in an orphaned context in Chrome and Firefox; Safari throws on property access instead, which is why the check is wrapped. Distinguishing eviction from invalidation matters: eviction is routine and the port should be re-established, while invalidation is permanent for that document and the only correct response is to clean up. Fixing extension context invalidated after an update covers the full recovery, including re-injecting into existing tabs.

MV3 constraints to design around

  • Updates apply on browser restart or worker idle. A user who never closes their browser can run an old version for weeks unless you call chrome.runtime.reload() from onUpdateAvailable.
  • onInstalled runs once per update, not once per startup. Anything that must be re-asserted on every start — dynamic script registrations, alarms, context menus — needs onStartup too.
  • There is no downgrade path. Users never move backwards, so migrations only need to go forward; storing a rollback is wasted effort.
  • The worker can be killed mid-migration. Every step must be resumable, which is what the per-step commit buys.
  • Content scripts are not re-injected into open tabs. The manifest only injects on navigation; existing tabs need chrome.scripting.executeScript or a reload.
  • chrome.storage has no transactions. A migration that must update two areas can be interrupted between them; design so the intermediate state is legible rather than corrupt.
  • Alarms survive updates but context menus do not. Menu items must be recreated from onInstalled and onStartup, as covered in rebuilding context menus after worker restarts.
What each engine does with a downloaded updateWhen the update is checked for, when it is applied, and whether the extension can influence the timing.BehaviourChrome / EdgeFirefoxSafariCheck frequency~5 hoursDailyApp StoreApplied whenIdle or restartEagerlyApp launchonUpdateAvailableFiresFiresNeverrequestUpdateCheckAvailableNot implementedNot meaningfulOpen tabs orphanedCommonVery commonRare
Only Chrome and Edge let you choose the moment — everywhere else the browser or the app decides.

Cross-browser notes

Chrome/Edge (baseline): auto-updates are checked roughly every five hours and applied when the extension is idle, meaning no worker running and no popup open. chrome.runtime.onUpdateAvailable lets you accept the update immediately by calling chrome.runtime.reload(); if you never do, the browser applies it on the next restart. chrome.runtime.requestUpdateCheck() forces a check and is rate-limited.

Firefox: the add-on manager checks daily by default and applies updates without waiting for idle in most cases, so the orphaned-content-script window is hit more often than in Chrome. browser.runtime.onUpdateAvailable and reload() exist with the same semantics. Firefox also preserves the extension’s UUID across updates, so any URL derived from it stays stable.

Safari: updates ship through the containing app, which means the App Store update cycle rather than a browser-managed one, and they apply when the app is next launched. onInstalled with reason: "update" fires as expected. Because the update is app-driven, requestUpdateCheck has no meaningful implementation and onUpdateAvailable does not fire — plan for the browser-restart path as the only delivery mechanism.

Where to go next

The pages under this guide take one step each. Running data migrations on onInstalled works through a real multi-step migration with rollback-free recovery. Fixing extension context invalidated after an update handles the orphaned tabs. Versioning message schemas across updates covers the protocol between surfaces that update at different moments. Controlling when an update is applied is about onUpdateAvailable, deferral, and not interrupting the user mid-task.