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.

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

You shipped a fix on Monday and a user reports the same bug on Thursday. Their browser downloaded the update on Monday afternoon and is still holding it, because the extension has never been idle: a side panel is open, a port is connected, and the browser will not swap a running worker out from under them. Meanwhile a different user gets the update mid-upload and loses the file they were sending. Both problems are the same decision made badly — nobody chose when the update applies. This page is part of Extension Updates & Data Migration and puts that choice in your code.

Root cause: the browser waits for idle, and idle may never come

Chrome checks for updates roughly every five hours, downloads what it finds, and then waits. It applies the update when the extension is idle — no service worker running, no extension pages open, no connected ports — or on the next browser restart. An extension with a persistent side panel, a long-lived port from a content script, or a keep-alive loop can prevent that idle state indefinitely.

Deciding when to accept a pending updateWhether to call runtime.reload immediately depends on whether the extension is in the middle of user-visible work.Is the extension in the middle of user work?NoApply nowruntime.reload()Worker restarts on the new buildmigrations run immediatelyYesDefer and markstore a pending flagApply when the work finishesor on the next browser startCritical fixApply and notifyreload plus a badgeTell the user why it interruptedsecurity or data-loss fixes only
The default of doing nothing is the branch that leaves users on a build from three weeks ago.

Calling chrome.runtime.reload() from onUpdateAvailable applies the downloaded version immediately. Doing it unconditionally is as wrong as never doing it — it can interrupt work in progress. The correct behaviour is a policy your extension states explicitly.

Step 1. Listen for the pending update

chrome.runtime.onUpdateAvailable fires once when a new version has been downloaded and is waiting. The listener must be registered at the top level, because the worker will be evicted and restarted many times before the event arrives.

1// sw.js — top level
2let pendingVersion = null;
3
4chrome.runtime.onUpdateAvailable.addListener(async (details) => {
5  pendingVersion = details.version;
6  await chrome.storage.session.set({ pendingUpdate: details.version });
7  await applyUpdateIfSafe();
8});

Execution context: Service worker. details.version is the version string of the waiting build, which is useful in a log and in the deferral UI. Persisting it to chrome.storage.session rather than only to a module variable means the decision survives an eviction between the event and the moment work finishes. Chrome and Firefox both fire this event; Safari does not, because its updates arrive through the containing app rather than a browser-managed download — code behind this listener simply never runs there, which is safe but means Safari users only ever update on app launch.

Step 2. Define what “busy” means for your extension

There is no browser-provided notion of busy. Decide it from the state you already track.

 1// sw.js
 2const busy = {
 3  uploads: 0,          // in-flight network work the user started
 4  editors: 0,          // open surfaces with unsaved input
 5  ports: new Set(),    // long-lived connections from content scripts
 6};
 7
 8function isBusy() {
 9  return busy.uploads > 0 || busy.editors > 0;
10}
11
12chrome.runtime.onConnect.addListener((port) => {
13  busy.ports.add(port);
14  port.onDisconnect.addListener(() => busy.ports.delete(port));
15});

Execution context: Service worker. Connected ports deliberately do not count as busy: a content script port is a passive channel that will be re-established after the reload, and treating it as work in progress is exactly what stops the update from ever applying. Uploads and unsaved editor state do count, because reloading destroys them. Because these counters live in worker memory they reset on eviction — which is correct, since an evicted worker had no in-flight work by definition.

Step 3. Apply at the first safe moment

Deferring an update until work completesThe pending update is recorded, the busy check defers it, and the same check re-runs when the last piece of work finishes.BrowserWorkerUpload taskNew buildonUpdateAvailable(3.3.0)isBusy() → true, deferupload finishes, counter hits zeroapplyUpdateIfSafe() againruntime.reload()worker restarts on the new version
The second call site is the one people forget — without it the deferral is permanent.
 1// sw.js
 2export async function applyUpdateIfSafe() {
 3  const { pendingUpdate } = await chrome.storage.session.get("pendingUpdate");
 4  if (!pendingUpdate) return false;
 5  if (isBusy()) return false;
 6
 7  // Close ports politely so content scripts see a clean disconnect rather than a severed one.
 8  for (const port of busy.ports) port.disconnect();
 9
10  console.info("applying update", pendingUpdate);
11  chrome.runtime.reload();          // nothing after this line runs
12  return true;
13}
14
15// Call it wherever a unit of work ends.
16export function finishUpload() {
17  busy.uploads = Math.max(0, busy.uploads - 1);
18  if (busy.uploads === 0) applyUpdateIfSafe();
19}

Execution context: Service worker. chrome.runtime.reload() terminates the current worker immediately — anything after it, including pending storage writes, does not happen, so flush state before calling it. Disconnecting ports first gives content scripts a normal onDisconnect they can distinguish from invalidation. In Firefox the same call applies a staged update identically; on Safari the function is a no-op in practice because pendingUpdate is never set.

Step 4. Force a check when the user asks

chrome.runtime.requestUpdateCheck() asks the store immediately rather than waiting for the next scheduled poll. It is rate-limited, so wire it to an explicit user action, not to a timer.

Update-control APIs and where they existThe four calls involved in controlling update timing, and which engines implement each.APIChrome / EdgeFirefoxSafariruntime.onUpdateAvailableFiresFiresNeverruntime.reload()Applies itApplies itRestarts onlyruntime.requestUpdateCheck()Rate-limitedAbsentAbsentruntime.getManifest().versionAvailableAvailableAvailable
Feature-probe every row — three of the four are absent somewhere.
 1// options.js — a "Check for updates" button
 2document.getElementById("check").addEventListener("click", async () => {
 3  const status = document.getElementById("check-status");
 4  status.textContent = "Checking…";
 5
 6  const result = await chrome.runtime.requestUpdateCheck();
 7  // Chrome returns { status, version }; older signatures used two callback arguments.
 8  const state = result?.status ?? result;
 9
10  status.textContent = {
11    update_available: `Version ${result?.version ?? ""} is ready. It will apply when you are idle.`,
12    no_update: "You are on the latest version.",
13    throttled: "Checked too recently — try again in a few minutes.",
14  }[state] ?? "Could not check right now.";
15});

Execution context: Options page, an ordinary document with a DOM; the listener is attached from an external file because inline handlers are blocked by the extension content security policy. requestUpdateCheck exists in Chrome and Edge only — Firefox does not implement it and Safari has nothing to check — so feature-detect before rendering the button at all, using the pattern in feature detection instead of browser sniffing. The throttled status is common during development and is not an error.

Step 5. Tell the user, without nagging

An update that will apply on its own does not need a dialog. An update that is being deferred because of their unsaved work does need a hint, or the deferral looks like a hang.

 1// sw.js
 2async function reflectPendingUpdate() {
 3  const { pendingUpdate } = await chrome.storage.session.get("pendingUpdate");
 4  if (!pendingUpdate) return;
 5
 6  await chrome.action.setBadgeText({ text: "↑" });
 7  await chrome.action.setBadgeBackgroundColor({ color: "#2563eb" });
 8  await chrome.action.setTitle({
 9    title: `Version ${pendingUpdate} is ready and will install when your work is saved.`,
10  });
11}

Execution context: Service worker; badge and title APIs are available there in all three engines. A badge is preferable to chrome.notifications here because it is passive and requires no additional permission — see updating the toolbar badge from the service worker for the state-management pattern behind it. Safari truncates badge text hard, so a single character is the safe choice; Firefox renders the arrow correctly at the default size.

Cross-browser variation

  • Chrome/Edge: checks roughly every five hours, applies on idle or restart. onUpdateAvailable, runtime.reload() and requestUpdateCheck() all available.
  • Firefox: checks daily by default and applies more eagerly, so deferral matters less but onUpdateAvailable and reload() work the same way. requestUpdateCheck is not implemented.
  • Safari: updates ship with the containing app through the App Store and apply at launch. onUpdateAvailable does not fire and there is nothing to defer; design so that the browser-restart path alone is sufficient.
  • All engines: runtime.reload() on an extension with no pending update simply restarts it on the same version, which is a useful development shortcut and a pointless interruption in production.

Verification

  1. Load an unpacked build, bump the version, and pack a new one to a locally hosted update URL. Confirm onUpdateAvailable logs the new version string.
  2. Set busy.uploads = 1 in the worker console, trigger the event, and confirm the update does not apply and the badge shows the pending marker.
  3. Call finishUpload() and confirm the worker reloads immediately and chrome.runtime.getManifest().version reports the new value.
  4. Click the options-page check button twice in quick succession and confirm the second returns throttled rather than erroring.
  5. Open a content script port, trigger the update, and confirm the port sees a clean onDisconnect rather than an invalidated context.

FAQ

Will an update ever apply while my worker is running?

Not in Chrome — it waits for idle or a restart. Firefox is more willing to apply while the extension is active, which is why the orphaned-tab handling matters there.

Is calling reload() on every onUpdateAvailable a reasonable default?

For an extension with no long-running user work, yes, and it is much better than never applying. Add the busy check only if there is state a reload would destroy.

Can I stop an update from applying at all?

No, and you should not want to. Deferral is temporary; the browser restart always wins.

Does reload() re-run onInstalled?

Only when the version actually changed. A reload on the same version fires onStartup-style cold-start behaviour but not onInstalled with reason: "update", which is why migrations should also run at script evaluation.

Other MV3 Architecture & Extension Lifecycle Resources