Cleaning Up Orphaned Content Scripts After Reload

Handle content scripts left running after an MV3 extension reloads or updates: detecting an invalidated context, a disconnect port as a signal, tearing down listeners and DOM, and avoiding duplicate instances.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

During development you reload the extension, switch back to a tab, and the console fills with “Uncaught Error: Extension context invalidated.” In production, the same happens to users after every update: content scripts injected by the previous version are still running in their open tabs, their event listeners still fire, their observers still observe, and every call to chrome.runtime or chrome.storage throws. If the new version re-injects, there are now two instances fighting over the page. An orphaned content script cannot be revived; it can only be shut down cleanly. This guide shows how a content script detects that it has been orphaned and removes itself. It belongs to content scripts and DOM injection.

What “orphaned” means

A content script’s JavaScript lives in the page’s renderer process, in an isolated world created for the extension. When the extension is reloaded, updated, disabled or uninstalled, Chrome severs that world’s link to the extension: the chrome.runtime connection is closed, chrome.runtime.id becomes undefined, open ports disconnect, and any extension API call throws “Extension context invalidated”. What Chrome does not do is unload the script’s code or undo its effects. Event listeners on document and window, timers, MutationObservers and DOM nodes the script created all remain. The script will keep reacting to the page — and failing every time it tries to talk to the extension — until the tab navigates or closes.

Life of a content script across an updateThe script runs normally; the extension updates and the context is invalidated; listeners keep firing and throwing until the script detects invalidation and tears itself down.page loadtab closedNormalcontext validOrphanedAPIs throw, listeners runTorn downsilent, no DOMextension updatedport onDisconnect / id check
Between invalidation and detection, the script is a ghost — make that window short.

Step-by-step: detect and tear down

1. Centralise teardown from the start

 1// content.js
 2const ctrl = new AbortController();
 3const cleanups = [];
 4const onTeardown = (fn) => cleanups.push(fn);
 5
 6function teardown(reason) {
 7  if (ctrl.signal.aborted) return;
 8  ctrl.abort();                                       // removes every listener registered with the signal
 9  for (const fn of cleanups.reverse()) { try { fn(); } catch {} }
10  console.debug("[readable] content script stopped:", reason);
11}

Execution context: the content script in the isolated world. Registering every addEventListener with { signal: ctrl.signal } and every other resource with onTeardown means one call removes everything. Teardown must only use DOM and web APIs, because extension APIs throw once the context is invalid.

2. Use a port disconnect as the primary signal

 1let port;
 2try {
 3  port = chrome.runtime.connect({ name: "content-alive" });
 4  port.onDisconnect.addListener(() => {
 5    // Disconnect happens on extension reload/update/disable — and if the worker drops the port
 6    if (!chrome.runtime?.id) teardown("context invalidated");
 7  });
 8} catch {
 9  teardown("connect failed");
10}

Execution context: the content script. An open port disconnects immediately when the extension’s context goes away, which makes it the fastest signal available. A port also disconnects if the service worker restarts in some cases, so check chrome.runtime.id before concluding the script is orphaned; if the id is still present, reconnect instead. The service worker does not need to do anything with these ports except not close them.

Detecting invalidation through a portThe content script connects a port at startup; the extension is reloaded; the browser disconnects the port; the content script checks runtime.id, finds it undefined, and tears down listeners, observers and DOM.Content scriptBrowserExtensionconnect({name:'content-alive'})reloaded / upda…port.onDisconnectruntime.id undefined → teardown
The port's disconnect arrives within milliseconds of the reload.

3. Back the port with a cheap periodic check

1const watchdog = setInterval(() => {
2  try { if (!chrome.runtime?.id) teardown("watchdog"); } catch { teardown("watchdog"); }
3}, 5_000);
4onTeardown(() => clearInterval(watchdog));

Execution context: the content script. Some engines and versions do not disconnect ports reliably on every kind of invalidation, and some pages block or delay execution. A five-second check costs nothing measurable and guarantees eventual cleanup. Reading chrome.runtime.id is safe after invalidation in Chrome (it is undefined); the try covers engines that throw instead.

4. Guard every extension API call

1async function safeSend(msg) {
2  if (ctrl.signal.aborted) return null;
3  try {
4    return await chrome.runtime.sendMessage(msg);
5  } catch (err) {
6    if (/context invalidated/i.test(String(err?.message))) teardown("send failed");
7    return null;
8  }
9}

Execution context: the content script. Even with fast detection, a user interaction can race the teardown. Wrapping calls means the first failure triggers cleanup and the user sees nothing worse than a no-op, instead of an uncaught error in the console — which also pollutes error reports from sites that collect console errors.

Invalidation signals comparedPort disconnect, a periodic runtime.id check, and a failed API call compared on detection speed, cost and reliability as signals that a content script is orphaned.SignalSpeedCostReliabilityPort onDisconnectImmediateOne open portHigh (Chrome)runtime.id check≤ intervalNegligibleHighAPI call throwsOn next useNoneOnly if used
Use all three: the port for speed, the check for certainty, the guard for races.

5. Remove DOM and observers in teardown

1const host = mountUi();                                   // your shadow-root host
2onTeardown(() => host.remove());
3
4const mo = new MutationObserver(onMutations);
5mo.observe(document.body, { childList: true, subtree: true });
6onTeardown(() => mo.disconnect());
7
8document.addEventListener("selectionchange", onSelection, { signal: ctrl.signal });

Execution context: the content script. Visible UI from an orphan — a button that does nothing when clicked — is what users notice; observers are what costs CPU. Both go in teardown. Inserted stylesheets added by the worker with insertCSS cannot be removed from here; the new version’s worker should remove or replace them.

6. Let the new instance clean up after the old one

1// first lines of content.js
2document.documentElement.dispatchEvent(new CustomEvent("readable:shutdown"));
3document.addEventListener("readable:shutdown", () => teardown("replaced"), { signal: ctrl.signal, once: true });
4document.querySelectorAll("readable-ui").forEach((el) => el.remove());

Execution context: the content script at startup. When the new version is injected — by your onInstalled re-injection or a navigation — it announces itself on the shared DOM before registering its own listener. Any old instance still listening tears itself down; any UI it left behind is removed directly. Ordering matters: dispatch first, then listen, so the new instance does not receive its own shutdown event.

7. Reduce noise during development

Reloading the extension dozens of times a day leaves orphans in every open tab. Either reload affected tabs after reloading the extension (development tools like WXT do this automatically), or rely on the teardown above, which makes stale tabs go quiet within seconds. Silence “Extension context invalidated” errors only by fixing their cause — never by wrapping the whole script in a try that hides real bugs.

Common mistakes

  • Using extension APIs in teardown. They throw once the context is invalid.
  • Listeners without a signal. They survive teardown.
  • Relying only on API failures. A script that rarely calls the extension may stay orphaned for hours.
  • Treating every port disconnect as invalidation. Check runtime.id and reconnect if it is still valid.
  • Leaving visible UI. Dead buttons look like a broken extension.

Cross-browser variation

  • Chrome / Edge: orphaned scripts remain running; port disconnect and runtime.id === undefined are dependable signals.
  • Firefox: usually unloads content scripts and removes their listeners when the extension is disabled or updated, but DOM changes remain; keep the leftover-cleanup step.
  • Safari: behaviour varies; the periodic check plus leftover cleanup covers it.

Verification

  1. Open a page with the UI mounted, reload the extension from chrome://extensions, and confirm the UI disappears within a moment.
  2. Confirm no “Extension context invalidated” errors appear when interacting with the page afterwards.
  3. Re-inject the new version and confirm exactly one UI instance exists.
  4. Record a Performance trace after reload and confirm no callbacks from the old instance’s observer.

FAQ

Can an orphaned content script reconnect to the new version?

No. Its context is permanently invalid. The new version must inject a new script.

Does the service worker need to track these ports?

No. It can ignore them; their only purpose is to disconnect when the extension goes away.

Will an open port keep the service worker alive?

Ports from content scripts can extend worker lifetime in some versions; if that is a concern, use only the periodic check.

Should orphaned scripts save their state before stopping?

They cannot — storage calls throw. Persist important state continuously while the context is valid, so nothing is lost when it ends.

Is there an event that tells content scripts the extension was updated?

No dedicated event exists. The port disconnect is the closest thing, which is why this guide builds on it, backed by the periodic check.

Other MV3 Architecture & Extension Lifecycle Resources