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.

6. The update as a user experiences it

Developers think of an update as a new version replacing an old one. For the user it is something that happens underneath whatever they were doing: a tab with a half-filled form driven by your content script, an open options page, a popup they are about to click, a long-running job in the background. Designing the update path around that experience removes most of the complaints updates generate.

Three situations cover almost all of it. Open pages with content scripts keep running the old code, disconnected from the new extension; the moment they call chrome.runtime, they get “Extension context invalidated”. They should detect that condition, stop quietly, and offer a reload only if the user was actively using the feature — the approach in fixing extension context invalidated after an update. Open extension pages — options, side panel — are reloaded by the browser along with the extension, losing unsaved input unless it was persisted as the user typed. Background work in progress stops with the old worker; jobs that were chained through storage and alarms resume in the new version, and jobs that lived only in memory are lost.

When the update is significant, a single, dismissible note in the popup — “Updated to 2.5: here is what changed” — is enough. It should never be a notification or a new tab opened on the user’s behalf.

7. Planning migrations across many versions

Users do not update in lockstep. Some install every version the day it ships; others leave a laptop closed for a month and jump from 2.1 straight to 2.6. A migration strategy that assumes the previous version was the one just before this one will eventually meet a profile it does not understand.

The robust shape is a numbered chain, where each step knows only how to go from schema n to n + 1 and every step is idempotent. Updating from any old version runs every step in order; running a step twice changes nothing. Keep a storage fixture captured from each released version, and a test that migrates every fixture to the current schema and validates the result, so a new step that breaks an old path fails in CI rather than on a user’s machine — the approach in testing an update before you publish it.

Downgrades need a rule too. A user on a rolled-back version, or with a newer version on another synced device, will present data from the future. Readers should tolerate unknown keys, writers should preserve them, and code that meets a schema number higher than its own should read what it understands and write nothing — so that the newer data is still intact when the user moves forward again.

8. Making updates boring

The goal of all of this is an update nobody notices. That is achieved less by clever migration code than by habits applied to every release: capture a storage fixture from each version as it ships, rehearse the update path by loading the previous build and then the new one over it, release gradually where the store allows, and watch error rates bucketed by version for the first hours of the rollout.

None of these steps is large, and together they turn an update from the riskiest moment in an extension’s life into a routine one. When something does go wrong despite them, a staged rollout limits how many users are affected and the version-bucketed error data shows exactly which release introduced the problem — the starting point for rolling back a bad extension release.

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.

Further guides in this topic

The guides below go deeper into specific extension updates and data migration problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.

  • Rolling Back a Bad Extension Release — Recover from a broken extension update — what each store can and cannot roll back, why a re-publish beats a rollback, and how to make the next version safe to downgrade into.
  • Testing an Update Before You Publish It — Rehearse an MV3 auto-update locally — load the old version, apply the new one over it, and verify migrations, orphaned content scripts and open tabs survive the swap.
  • Checking for Updates with requestUpdateCheck — Control when an MV3 extension picks up new versions: how automatic updates work, runtime.requestUpdateCheck and its throttling, onUpdateAvailable, applying updates with runtime.reload, and when not to force them.
  • Migrating IndexedDB Schemas on Update — Upgrade IndexedDB schemas safely in an MV3 extension: onupgradeneeded versioning, migrating data between stores, blocked upgrades from open popups and panels, batching large migrations, and rolling back.
  • Showing a What’s New Page After an Update — Announce new features after an MV3 extension update without annoying users: detecting meaningful version changes in onInstalled, badge hints instead of tabs, an in-extension changelog, and opt-out.
  • Staged Rollouts in the Chrome Web Store — Release MV3 extension updates gradually: Chrome Web Store percentage rollouts, eligibility, choosing stages, monitoring errors and reviews per version, halting a bad rollout, and equivalents on Edge and AMO.

Checking for Updates with requestUpdateCheck

Control when an MV3 extension picks up new versions: how automatic updates work, runtime.requestUpdateCheck and its throttling, onUpdateAvailable, applying updates with runtime.reload, and when not to force them.

Migrating IndexedDB Schemas on Update

Upgrade IndexedDB schemas safely in an MV3 extension: onupgradeneeded versioning, migrating data between stores, blocked upgrades from open popups and panels, batching large migrations, and rolling back.

Showing a What's New Page After an Update

Announce new features after an MV3 extension update without annoying users: detecting meaningful version changes in onInstalled, badge hints instead of tabs, an in-extension changelog, and opt-out.

Staged Rollouts in the Chrome Web Store

Release MV3 extension updates gradually: Chrome Web Store percentage rollouts, eligibility, choosing stages, monitoring errors and reviews per version, halting a bad rollout, and equivalents on Edge and AMO.

Rolling Back a Bad Extension Release

Recover from a broken extension update — what each store can and cannot roll back, why a re-publish beats a rollback, and how to make the next version safe to downgrade into.

Testing an Update Before You Publish It

Rehearse an MV3 auto-update locally — load the old version, apply the new one over it, and verify migrations, orphaned content scripts and open tabs survive the swap.

Controlling When an Update Is Applied

Use runtime.onUpdateAvailable and reload() to apply an MV3 extension update at a safe moment, defer it during active work, and force a check without waiting for the browser.

Fixing Extension Context Invalidated After an Update

Why open tabs throw Extension context invalidated after an auto-update, how to tell orphaning from worker eviction, and how to retire or re-inject the old content script.

Running Data Migrations on onInstalled

Write MV3 storage migrations that survive worker eviction: an ordered step list, per-step commits, a readiness gate, and a failure path that does not lose user data.

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.