← Blog

"Extension context invalidated" — what it means and how to fix it

This error appears when your extension updates or reloads while a content script is still running on a page. Here's the exact cause, why it's so common in development, and the patterns that make content scripts survive it.

If you build extensions, you’ve seen it in a page’s console, usually in red, usually at the worst time:

Uncaught Error: Extension context invalidated.

It looks alarming and it’s poorly explained by the platform. The good news: the cause is specific and well understood, and once you know it, the fix is a pattern you apply once.

What actually happened

A content script runs in the web page, but it is owned by your extension. It talks back to the extension through the chrome.* APIs — chrome.runtime.sendMessage, chrome.storage, and so on. Those APIs are a bridge to the extension’s own runtime (its service worker and its context).

“Extension context invalidated” means that bridge was torn down while the content script was still standing on it. The content script’s JavaScript is still executing in the page, but the extension it belongs to is no longer the same running instance. Any chrome.* call now throws, because there’s nothing on the other end.

This happens whenever the extension’s runtime is replaced or removed out from under an already-injected content script:

The key insight: old content scripts are not automatically removed from already-open tabs when the extension reloads. The page keeps running the stale script, but its lifeline to the extension is cut. The script is now an orphan.

Why development makes it feel worse than it is

In development you reload the extension dozens of times an hour. Every reload orphans every content script on every open tab. So you see the error constantly and start to think it’s a fragile mess.

In production it’s rarer — it only happens when the store auto-updates your extension while a user has a relevant tab open — but it’s also more important there, because a real user hits it, sees a broken feature, and can’t just reload the extensions page the way you can.

So the goal isn’t to make the error go away in development. It’s to make your content script degrade gracefully when its context is gone, whether that’s from your dev reload or a user’s mid-session update.

The naive fix, and why it’s not enough

The first instinct is to wrap chrome.* calls in try/catch:

try {
  chrome.runtime.sendMessage({ type: "ping" });
} catch (e) {
  // swallow "Extension context invalidated"
}

This stops the console error, but it doesn’t fix the underlying problem: your content script is still an orphan, still running, still holding event listeners on the page, still trying to do work it can no longer complete. You’ve silenced the symptom and kept the disease.

The patterns that actually work

1. Detect invalidation explicitly

You can check whether the bridge is still alive before you use it. chrome.runtime?.id is undefined once the context is invalidated:

function extensionAlive() {
  return Boolean(chrome.runtime?.id);
}

function safeSend(message) {
  if (!extensionAlive()) {
    teardown(); // stop trying; clean up
    return;
  }
  chrome.runtime.sendMessage(message);
}

2. Tear down cleanly when the context is gone

An orphaned content script should stop being a content script. Remove the DOM nodes you injected, disconnect your MutationObservers, remove your event listeners. A page left with a dead script’s listeners still attached is how you get duplicated UI and doubled event handlers after the next injection.

function teardown() {
  observer?.disconnect();
  window.removeEventListener("scroll", onScroll);
  injectedRoot?.remove();
}

3. Expect re-injection, and make it idempotent

After an update or reload, your fresh content script will be injected into the page (immediately for newly-loaded tabs; on next navigation or via your reinjection logic for existing ones). If your old script didn’t clean up, the new one now coexists with a dead twin. Design injection so running it twice is safe: check for your own marker before injecting, and remove any previous instance’s DOM first.

const MARKER = "data-exo-injected";
if (document.documentElement.hasAttribute(MARKER)) {
  // a previous instance is here — clean it up first
  document.querySelectorAll("[data-exo-ui]").forEach((n) => n.remove());
}
document.documentElement.setAttribute(MARKER, "1");

4. Catch the async form too

The error also surfaces from message responses, not just sends. When the worker on the other end dies mid-request, the callback receives a chrome.runtime.lastError. Check it, don’t let it throw uncaught:

chrome.runtime.sendMessage(msg, (response) => {
  if (chrome.runtime.lastError) {
    teardown();
    return;
  }
  // use response
});

The bigger picture

“Extension context invalidated” is really a symptom of a structural truth about extensions: content scripts and the extension runtime have independent lifecycles, and either can be replaced without the other’s consent. The content script can outlive the runtime that injected it; the runtime (a service worker) can die and restart under a still-running content script.

Robust extensions treat every cross-context call as something that can fail because the other side vanished — detect it, tear down, and let re-injection start clean. Do that once, as a pattern, and the red console error stops being a recurring mystery and becomes a normal, handled edge.


Part of a series mapping every layer of the browser-extension stack — see also why your MV3 background keeps dying, the other half of the extension-lifecycle problem.

Building this for real?

Exo is an application framework for browser-native software — the patterns in this post are the ones it paves over. It's in alpha and we're onboarding founding partners.

Join the Alpha More posts