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.
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.
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.
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.
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.idand 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 === undefinedare 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
- Open a page with the UI mounted, reload the extension from
chrome://extensions, and confirm the UI disappears within a moment. - Confirm no “Extension context invalidated” errors appear when interacting with the page afterwards.
- Re-inject the new version and confirm exactly one UI instance exists.
- 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.
Related
- Removing injected UI cleanly — the UI half of teardown.
- Injecting into already open tabs after install — bringing the new version in.
- Fixing extension context invalidated after an update — the error itself.
- Content scripts and DOM injection — the parent topic.