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.

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

Uncaught Error: Extension context invalidated. appears in the console of a page you have not touched, thrown from a content script that was working ten minutes ago. Nothing you did caused it: the browser auto-updated the extension, and the script still running in that tab now belongs to a version that no longer exists. This page is part of Extension Updates & Data Migration and covers detection, clean retirement, and re-injecting a live version into tabs the user has not reloaded.

Root cause: the document outlives the extension that injected it

A content script runs in the page’s process, in an isolated world, holding a handle to the extension. When the extension is updated or reloaded, that handle is severed — the extension the handle pointed at is gone. The DOM the script built stays on the page, its event listeners still fire, and the first chrome.* call any of them makes throws. The page is fine; the script is a ghost.

What survives an update in an open tabThe page, its DOM and the injected script keep running, while the extension handle and the service worker behind it are replaced.Page documentunchanged, still loadedthe user sees no differenceInjected DOM and listenersyour buttons, observers, stylesstill firingOld content script contextchrome.runtime.id now undefinedthrows on any API callNew service workerfresh version, no port to this tabcannot reach the old script
Everything above the dashed boundary keeps running with a handle to something that no longer exists.

The same error appears in three situations that need different responses: an auto-update, a developer clicking reload on the extensions page, and the extension being disabled or uninstalled. Only the first is recoverable by re-injection; the others end with the script simply stopping.

Step 1. Detect orphaning cheaply and safely

chrome.runtime.id is the canonical probe. Reading it in an invalidated context returns undefined in Chrome and Firefox and can throw in Safari, so wrap it.

1// content.js
2function extensionAlive() {
3  try {
4    return Boolean(chrome?.runtime?.id);
5  } catch {
6    return false;            // Safari throws rather than returning undefined
7  }
8}

Execution context: Content script, isolated world of the page. This is a synchronous property read with no permission requirement and no measurable cost, so it is safe to call before every API use. It does not tell you whether the worker is currently running — an evicted worker still leaves chrome.runtime.id populated, because the extension itself is very much alive. That distinction is the one everything else on this page depends on.

Step 2. Route every extension call through one guard

Scattering try/catch at call sites produces a script that half-works after an update. One wrapper that fails closed is easier to reason about.

Reading the error you actually gotTwo error strings that look similar mean opposite things: one is a permanent orphaning, the other a routine worker eviction.What did the failed call report?context invalidatedTerminal for this documentthe extension was replacedretire() and never retryonly a reload or re-injection helpsreceiving end does not existWorker was asleeproutine, recoverableRetry or ignorethe next event wakes itanything elseRethrowa real bug in your handlerDo not swallow itit will not fix itself
Confusing these two produces either a script that gives up too early or one that retries forever.
 1// content.js
 2let retired = false;
 3
 4async function callExtension(message) {
 5  if (retired || !extensionAlive()) { retire(); return null; }
 6  try {
 7    return await chrome.runtime.sendMessage(message);
 8  } catch (error) {
 9    if (String(error).includes("Extension context invalidated")) { retire(); return null; }
10    if (String(error).includes("Receiving end does not exist")) return null;  // worker asleep, harmless
11    throw error;
12  }
13}

Execution context: Content script. The two error strings mean opposite things and are routinely confused. “Extension context invalidated” is terminal for this document. “Could not establish connection. Receiving end does not exist.” usually means the worker was evicted and no listener was registered in time — a transient condition covered in fixing message port closed before response errors. Chrome and Firefox use these exact phrasings; Safari’s wording differs, which is another reason the extensionAlive probe runs first.

Step 3. Retire the ghost completely

Retirement means leaving the page exactly as your script found it, and never firing again.

 1// content.js
 2const cleanup = new Set();     // every listener, observer and node registers here
 3
 4function track(undo) { cleanup.add(undo); return undo; }
 5
 6function retire() {
 7  if (retired) return;
 8  retired = true;
 9  for (const undo of cleanup) {
10    try { undo(); } catch { /* the page may already have removed the node */ }
11  }
12  cleanup.clear();
13}
14
15// Registration looks like this everywhere in the script:
16const observer = new MutationObserver(onMutate);
17observer.observe(document.body, { childList: true, subtree: true });
18track(() => observer.disconnect());
19
20const btn = document.createElement("button");
21document.body.append(btn);
22track(() => btn.remove());

Execution context: Content script, page process. Collecting undo functions as you go is the only reliable way to unwind: by the time you discover the context is dead, you cannot ask the extension what it created. MutationObserver in particular will keep calling your handler forever if it is not disconnected, and each call will throw once the handler touches chrome.* — the console fills with hundreds of identical errors, which is how most people first notice this bug. All three engines behave the same here because none of it is extension API.

Step 4. Detect the update from the worker and re-inject

The new worker knows an update happened. It can put a live script back into tabs the user never reloaded, so features keep working without a visible interruption.

Re-injecting after an updateThe new worker enumerates matching tabs, injects the current content script, and the fresh script signals the old one to retire before taking over.New workertabs / scriptingOld scriptNew scripttabs.query({ url: matches })scripting.executeScript({ files })new script evaluates in the same pagedispatch ext-retire CustomEventthe only channel that still worksretire(): undo everythingready — resume normal messaging
The old script must retire first, or two versions manipulate the same DOM.
 1// sw.js
 2chrome.runtime.onInstalled.addListener(async (details) => {
 3  if (details.reason !== "update") return;
 4
 5  const tabs = await chrome.tabs.query({ url: ["https://*/*"] });
 6  const results = await Promise.allSettled(tabs.map((tab) =>
 7    chrome.scripting.executeScript({
 8      target: { tabId: tab.id, allFrames: false },
 9      files: ["content.js"],
10    })
11  ));
12
13  reportUninjectableTabs(results);
14});

Execution context: Service worker, after the update. Requires the scripting permission plus host permissions matching the tabs — activeTab is not enough, because there is no user gesture here. Promise.allSettled rather than Promise.all matters: injection fails for chrome:// pages, the Web Store, PDF viewers and discarded tabs, and one rejection must not abandon the rest. Firefox supports the same call from Manifest V3; Safari supports it from version 17 but skips more frames silently, so treat re-injection as best-effort everywhere.

1// content.js — first lines, before anything else runs
2document.dispatchEvent(new CustomEvent("ext-retire", { detail: { version: chrome.runtime.getManifest().version } }));
3
4document.addEventListener("ext-retire", (event) => {
5  if (event.detail?.version !== chrome.runtime.getManifest().version) retire();
6});

Execution context: Content script, isolated world. A CustomEvent on document is the one channel that still reaches an invalidated script, because it involves no extension API at all — both scripts share the page’s DOM even though they belong to different extension contexts. Announcing your own version and retiring on any announcement that is not yours makes the handshake symmetric, so it also works if three versions somehow end up layered. Guard against re-entrancy: the new script must not retire itself, which is what the version comparison does.

Step 5. Tell the user when re-injection is not possible

Some tabs cannot be re-injected — no host permission, a restricted URL, or a frame the engine skips. If your extension’s value depends on the script being live, say so rather than appearing broken.

1// sw.js
2async function reportUninjectableTabs(results) {
3  const failed = results.filter((r) => r.status === "rejected").length;
4  if (failed === 0) return;
5  await chrome.action.setBadgeText({ text: String(Math.min(failed, 9)) });
6  await chrome.action.setTitle({ title: `${failed} tab(s) need a reload to finish updating` });
7}

Execution context: Service worker. A badge is the least intrusive signal available and does not require a notification permission. Avoid reloading tabs on the user’s behalf: a forced chrome.tabs.reload after an auto-update can destroy unsaved form input, which is a far worse outcome than a feature being unavailable until the next navigation.

Cross-browser variation

  • Chrome/Edge: chrome.runtime.id becomes undefined. The error message is exactly “Extension context invalidated.” Auto-updates apply when the extension is idle, so orphaning is common on long-lived tabs.
  • Firefox: browser.runtime.id also becomes undefined, with a differently worded error. Firefox applies updates more eagerly than Chrome, making this the engine where users hit it most.
  • Safari: property access on an invalidated browser.runtime can throw, so the probe must be wrapped. Updates arrive with the containing app, typically at launch, so open tabs are less likely to be affected.
  • All engines: a developer clicking “reload” on the extensions page produces exactly the same symptom as an auto-update, which is why it is so easy to dismiss as a development-only artefact.

Verification

  1. Open a page your content script matches, then click reload on the extensions page. The old script’s injected UI should disappear cleanly and the console should show no repeated errors.
  2. With re-injection enabled, bump the version and load the new build. The injected UI should reappear within a second without reloading the tab.
  3. Open a chrome://extensions tab alongside a matched page and confirm the injection pass does not throw — allSettled should absorb the restricted-URL rejection.
  4. Add a MutationObserver that logs on every mutation, then invalidate the context. After retirement the log should stop entirely.
  5. Check the badge count matches the number of tabs where injection genuinely failed.

FAQ

Can I catch this error globally instead?

A window.onerror handler in the isolated world sees the throw, but by then the failing operation is already lost. The guard in step 2 fails closed before the call is attempted, which is why it is preferable.

Does reloading the tab fix it?

Yes, completely — a fresh navigation injects the current version. That is the manual fallback when re-injection is not possible.

Why does the error appear during normal development?

Because the extensions-page reload button invalidates every context exactly as an update does. Building the retirement path early means development stops producing console noise too.

Should I re-inject on chrome_update as well?

No. A browser update restarts the browser, so tabs reload and the manifest injects normally. Re-injection is only needed when the extension changed under a running browser.

Other MV3 Architecture & Extension Lifecycle Resources