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.
Table of Contents
- Root cause: the browser waits for idle, and idle may never come
- Step 1. Listen for the pending update
- Step 2. Define what “busy” means for your extension
- Step 3. Apply at the first safe moment
- Step 4. Force a check when the user asks
- Step 5. Tell the user, without nagging
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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
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.
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()andrequestUpdateCheck()all available. - Firefox: checks daily by default and applies more eagerly, so deferral matters less but
onUpdateAvailableandreload()work the same way.requestUpdateCheckis not implemented. - Safari: updates ship with the containing app through the App Store and apply at launch.
onUpdateAvailabledoes 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
- Load an unpacked build, bump the version, and pack a new one to a locally hosted update URL. Confirm
onUpdateAvailablelogs the new version string. - Set
busy.uploads = 1in the worker console, trigger the event, and confirm the update does not apply and the badge shows the pending marker. - Call
finishUpload()and confirm the worker reloads immediately andchrome.runtime.getManifest().versionreports the new value. - Click the options-page check button twice in quick succession and confirm the second returns
throttledrather than erroring. - Open a content script port, trigger the update, and confirm the port sees a clean
onDisconnectrather 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.
Related
- Running data migrations on onInstalled — what runs the moment the reload lands.
- Fixing extension context invalidated after an update — the tabs affected by the moment you chose.
- Keeping service workers alive during long tasks — the keep-alive patterns that can starve the idle state.
- Extension Updates & Data Migration — the guide this page belongs to.