Skip to content

"Extension Context Invalidated": Why Content Scripts Break After an Update

6 min readModerok team

What the "Extension context invalidated" error means, why it hits content scripts after every extension update or reload, and how to detect and recover.

If your console is showing Uncaught Error: Extension context invalidated., here is the short version: the content script running on that page belongs to a version of your extension that no longer exists. The extension was updated, reloaded, disabled, or uninstalled, and the browser tore down its side of the connection. The JavaScript already injected into the page keeps running, but every call it makes into chrome.* now throws. Nothing is wrong with your code; the tab is just holding a ghost. Here is how to detect an invalidated extension context and what to do about the orphaned scripts.

What "Extension context invalidated" actually means

A content script lives in two worlds. Its DOM access belongs to the page; its chrome.runtime, chrome.storage, and messaging APIs belong to the extension. When the extension goes away, only the second half disappears.

The page does not reload. Your event listeners are still attached, your injected UI is still in the DOM, your setInterval is still ticking. The next time any of that code touches an extension API, Chrome throws Extension context invalidated. because there is no longer an extension process to service the call.

The W3C WebExtensions group has an open issue on exactly this gap, webextensions#138: "Context scripts continue running even after their extension is uninstalled or disabled, and they have no convenient way to be notified of these events to start their cleanup." Chrome, Firefox, and Safari have all labeled it supportively, but until a real onUninstalled event for content scripts ships, this is a workaround problem.

What triggers it

Four things invalidate an already-injected content script: an update (from the Web Store or an enterprise policy), a manual reload on chrome://extensions, disabling the extension, and uninstalling it. That reload button is why the error dominates development and rarely appears in a stable build.

Declaratively registered content scripts are injected on navigation, so any tab opened after the new version installs behaves normally. Only tabs already open at the moment of the swap end up orphaned, and Chrome neither reloads them nor re-runs the new script in them. Hence the confusion: the extension works fine in a new tab and is broken in the one you were staring at.

Chrome does soften this for real installs: per the update lifecycle, an update is applied once the extension is idle, which in MV3 mostly means the service worker is not running. Development reloads have no such courtesy, which is why this feels like a dev-only problem right up until a user reports it.

How to detect an invalidated context

There is no event, but there is a reliable synchronous check: chrome.runtime.id is only defined while the context is alive. After invalidation it is undefined, and since chrome.runtime itself may be lazily initialized, test it with optional chaining: chrome.runtime?.id == null means the context is dead. That is not folklore; it is what WXT does inside its ContentScriptContext:

get isInvalid(): boolean {
  if (browser.runtime?.id == null) {
    this.notifyInvalidated(); // Sets `signal.aborted` to true
  }
  return this.signal.aborted;
}

In WXT, the ctx passed to a content script's main() is an AbortController, and ctx.onInvalidated(cb) registers a cleanup callback. Read that getter carefully, though: WXT has no push signal either. notifyInvalidated() fires when something reads ctx.isInvalid, or when a newer instance of the same content script starts and broadcasts to the old ones. That is why the wrapped timers matter more than they look. ctx.setInterval re-checks validity every tick, so a script built on them notices on its own; one that only registers DOM listeners will not.

Without a framework, guard the boundary rather than sprinkling checks everywhere. Wrap the one or two functions that talk to the background:

async function send(message) {
  if (chrome.runtime?.id == null) {
    teardown();
    return null;
  }
  try {
    return await chrome.runtime.sendMessage(message);
  } catch (error) {
    // The context can die between the check and the call.
    teardown();
    return null;
  }
}

Keep the try/catch even with the guard: the extension can be updated in the gap between the check and the call landing, and this error arrives as a thrown exception, not as a chrome.runtime.lastError you have to remember to read.

Clean up instead of throwing

An orphaned script that keeps polling is a memory leak on a tab nobody can see into. teardown() should do the boring thing, completely:

function teardown() {
  clearInterval(pollTimer);
  observer.disconnect();
  document.removeEventListener("keydown", onKeydown);
  document.querySelector("#my-ext-root")?.remove();
}

A long-lived port is the usual push-based suggestion. chrome.runtime.connect() returns a Port whose onDisconnect fires when the other end goes away, including when the extension is unloaded. Wiring that straight to teardown is a trap in MV3:

// Wrong: disconnect does not mean "invalidated".
const port = chrome.runtime.connect({ name: "lifecycle" });
port.onDisconnect.addListener(teardown);

The port also disconnects every time the service worker idles out, which it does after 30 seconds of inactivity, and since Chrome 114 merely opening a port no longer resets that timer. So this version rips out perfectly healthy UI every half minute. onDisconnect has other causes too, such as the other end calling port.disconnect(). Treat it as a hint and confirm before acting:

const port = chrome.runtime.connect({ name: "lifecycle" });
port.onDisconnect.addListener(() => {
  if (chrome.runtime?.id == null) teardown();
  // Otherwise the worker just went to sleep. Reconnect if you need the port.
});

The runtime.id check is doing the real work either way. For more on why that worker keeps disappearing, see why your service worker keeps stopping.

Re-inject content scripts after an update

To make already-open tabs work again without asking users to refresh, re-inject from the service worker. chrome.runtime.onInstalled fires with reason: "update" in the new version:

chrome.runtime.onInstalled.addListener(async ({ reason }) => {
  if (reason !== "update") return;

  for (const script of chrome.runtime.getManifest().content_scripts ?? []) {
    // `js` is optional: a css-only entry has nothing to execute, and
    // executeScript rejects an injection with neither `files` nor `func`.
    if (!script.js?.length) continue;

    const tabs = await chrome.tabs.query({ url: script.matches });
    for (const tab of tabs) {
      if (tab.id == null) continue;
      const target = { tabId: tab.id, allFrames: script.all_frames };
      chrome.scripting.executeScript({
        target,
        files: script.js,
        injectImmediately: true,
      }).catch(() => {
        // Restricted pages (chrome://, the Web Store) will reject. Expected.
      });
    }
  }
});

This needs the scripting permission and host access to the URLs you inject into. If those content scripts also declare css, re-inject it with chrome.scripting.insertCSS() in the same loop or the new script builds its UI unstyled. Note also that tabs.query filters on matches alone. If an entry declares exclude_matches, include_globs, or exclude_globs, apply those yourself before injecting or you will run the script on pages the manifest deliberately excludes.

Two more things to design for: the new script lands on a page where the old one already built that UI, so make injection idempotent (check for your root element and bail, or remove it first), and the old script is still running alongside the new one. WXT handles the second by having each new instance broadcast a start message that tells older instances to abort; do the same if you roll your own.

reason also reports "install" and "chrome_update", which are useful well beyond re-injection. See detecting first install vs. update with chrome.runtime.onInstalled.

The analytics blind spot nobody notices

The standard pattern for extension analytics is to sendMessage from content scripts and popups and do the tracking in the service worker (what Moderok's docs recommend too, since the SDK ships no message bus). That pattern has a hole: every such message from an orphaned content script throws, and if you swallow the error you have silently dropped analytics from every tab that was open when you shipped the update.

It will not look like an error spike. It looks like a dip in events right after a release, on the day you are least inclined to believe your data. The orphaned script cannot even report its own failure, since reporting means another sendMessage into the same dead context. Two things do work. Keep anything you cannot afford to lose on the background side, emitted from the service worker rather than relayed from a tab holding a dead handle. And mark your release boundaries so the dip reads as a release artifact instead of a mystery: Moderok emits an automatic __update event carrying previousVersion when chrome.runtime.onInstalled fires with reason: "update".

Moderok is an analytics SDK for MV3 extensions that sends install, update, and daily activity events out of the box, plus error capture via Moderok.captureError(). The error tracking guide covers what it records automatically.