Removing Injected UI Cleanly

Tear down extension UI injected into pages: one AbortController for listeners, disconnecting observers, removing hosts and highlights, detecting an invalidated extension context, and avoiding duplicates after updates.

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

After an extension update, every open tab shows two floating buttons — the old one, which does nothing when clicked, and the new one. Or the user turns a feature off in the options page and its highlights stay on every open tab until reload. Or a content script keeps a MutationObserver running long after its UI is gone, quietly costing CPU on every DOM change. Injected UI has to be removable on demand, and the moments that demand it — updates, disabling, feature toggles — are exactly the moments when the code that created it may no longer be able to talk to the extension. This guide builds teardown in from the start. It belongs to in-page overlays and injected UI.

Why injected UI outlives its extension

A content script’s DOM changes belong to the page, not to the script. When the extension is updated, reloaded or disabled, Chrome severs the old content script from the extension — chrome.runtime calls throw “Extension context invalidated”, chrome.runtime.id becomes undefined, ports disconnect — but it does not unload the script’s JavaScript or undo its DOM changes. Event listeners keep firing, timers keep running, observers keep observing, and every element you added stays where it is. If the new version’s content script is then injected (declared scripts are not re-injected into already-open tabs automatically in Chrome, but scripts you inject on update are), there are now two copies of everything. Clean removal therefore needs two mechanisms: a teardown the old script runs on itself when it notices it is orphaned, and a cleanup the new script runs on whatever it finds left behind.

A tab across an extension updateVersion 1's content script mounts UI; the extension updates and version 1's context is invalidated while its DOM remains; version 1 detects invalidation and tears down; version 2 is injected, removes leftovers and mounts fresh UI.page load (v1)v2 runningv1 UI mountedUpdate: v1 orphanedDOM still therev1 self-…v2 injec…remove le…v2 UI mountedchrome.runtime.id → undefinedv2 checks for leftover v1 nodes
Two clean-ups cover the gap: the old script on itself, the new script on what remains.

Step-by-step: teardown by design

1. Give every feature one owner object

 1// content script
 2export function startFeature(name, setup) {
 3  const ctrl = new AbortController();
 4  const cleanups = [];
 5  const api = {
 6    signal: ctrl.signal,
 7    onTeardown: (fn) => cleanups.push(fn),
 8  };
 9  setup(api);
10  return function teardown() {
11    if (ctrl.signal.aborted) return;                 // idempotent
12    ctrl.abort();
13    for (const fn of cleanups.reverse()) { try { fn(); } catch {} }
14  };
15}

Execution context: the content script. Every listener registered with api.signal is removed by the single abort(); everything that cannot take a signal — observers, intervals, highlight registrations, DOM nodes — registers a cleanup function. Running cleanups in reverse order unwinds the setup the way it was built. Making teardown idempotent lets you call it from several triggers without guarding each.

2. Register everything through the owner

 1const stopFab = startFeature("fab", ({ signal, onTeardown }) => {
 2  const { host, root } = mountHost("readable-fab");
 3  onTeardown(() => host.remove());
 4
 5  document.addEventListener("selectionchange", onSelection, { signal });
 6  addEventListener("scroll", reposition, { passive: true, capture: true, signal });
 7
 8  const mo = new MutationObserver(onMutations);
 9  mo.observe(document.body, { childList: true, subtree: true });
10  onTeardown(() => mo.disconnect());
11
12  const timer = setInterval(refreshBadge, 60_000);
13  onTeardown(() => clearInterval(timer));
14
15  onTeardown(() => CSS.highlights?.delete("readable-match"));
16});

Execution context: the content script. The discipline is simple: nothing is added to the page or the global environment without a matching line that removes it. Code review can check it mechanically — any addEventListener without signal, any observe without a cleanup, is a leak.

What one teardown call removesAborting the controller removes every listener; registered cleanups disconnect observers, clear timers, delete highlights and remove the host element.teardown()idempotentctrl.abort()all listenerscleanups (reverse)registered at setupeach cleanup undoes one setup stepmo.disconnect()observersclearIntervaltimershost.remove()DOM
One call, no leftovers — because nothing was added without a matching removal.

3. Detect an invalidated context

 1function contextAlive() {
 2  try { return Boolean(chrome.runtime?.id); } catch { return false; }
 3}
 4
 5// Check cheaply on interaction and periodically
 6document.addEventListener("pointerdown", () => { if (!contextAlive()) teardownAll(); }, { capture: true });
 7const watchdog = setInterval(() => { if (!contextAlive()) teardownAll(); }, 5_000);
 8
 9// And on any failed call
10async function send(msg) {
11  try { return await chrome.runtime.sendMessage(msg); }
12  catch (err) {
13    if (/context invalidated/i.test(err?.message ?? "")) teardownAll();
14    throw err;
15  }
16}

Execution context: the content script. There is no event for “your extension was updated”; a port’s onDisconnect is the closest signal if you keep one open, and polling chrome.runtime.id is the dependable fallback. A five-second watchdog is cheap. teardownAll calls every feature’s teardown and clears the watchdog itself. See fixing “extension context invalidated” after an update.

4. Clean up leftovers when a new instance starts

1// first lines of the content script
2const VERSION = chrome.runtime.getManifest().version;
3for (const el of document.querySelectorAll("readable-fab, readable-ui, readable-panel")) {
4  if (el.dataset.version !== VERSION) el.remove();
5}
6CSS.highlights?.delete("readable-match");
7document.documentElement.dispatchEvent(new CustomEvent("readable:teardown"));   // ask old instances to stop

Execution context: the content script, at startup. Removing hosts tagged with an older version (set host.dataset.version when mounting) cleans up after orphans that never noticed they were orphaned. The custom event lets an old instance that is still listening run its full teardown, including listeners the new instance cannot see — both instances share the DOM, so the event crosses between their isolated worlds. Old instances should listen for it with { once: true }.

Handing over from an orphaned instanceThe new content script removes outdated hosts, dispatches a teardown event on the document element, the old instance receives it and aborts its listeners and observers, then the new instance mounts.New instance (v2)Shared DOMOld instance (v1)remove hosts with version ≠ v2dispatch readable:teardownevent deliveredabort listeners…mount v2 host
The shared DOM is the only channel between two instances in different isolated worlds.

5. Tear down on request from the extension

1chrome.runtime.onMessage.addListener((msg) => {
2  if (msg?.type === "feature:off" && msg.feature === "fab") stopFab();
3});
4
5chrome.storage.onChanged.addListener((changes, area) => {
6  if (area === "sync" && changes.fabEnabled?.newValue === false) stopFab();
7});

Execution context: the content script. Turning a feature off in the options page should remove it from every open tab immediately. Listening to storage.onChanged reaches every tab without the worker having to message each one; a direct message is useful for per-tab actions. Remember to register these listeners through the feature owner too, or at least through a global one, so they are removed in teardownAll.

6. Re-inject into open tabs after an update, deliberately

1// sw.js
2chrome.runtime.onInstalled.addListener(async ({ reason }) => {
3  if (reason !== "update") return;
4  const tabs = await chrome.tabs.query({ url: ["https://*.example.com/*"] });
5  for (const tab of tabs) {
6    chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content.js"] }).catch(() => {});
7  }
8});

Execution context: the service worker. Chrome does not re-inject manifest content scripts into tabs that were already open when the extension updated, so without this, open tabs keep a dead old instance until reload. Re-injecting runs the new script’s leftover cleanup (step 4) and mounts fresh UI. This requires host access to those tabs.

Common mistakes

  • Listeners without signals. Every one is a leak that keeps firing after teardown and after the context dies.
  • Observers on document that are never disconnected. They are the most expensive leak, running on every DOM change for the life of the tab.
  • Relying on page reload for cleanup. Users keep tabs open for weeks; your orphaned UI stays that long.
  • Not tagging hosts with a version. The new instance cannot tell its own UI from an orphan’s.
  • Calling chrome.* in teardown. By the time the context is invalidated, those calls throw. Teardown must use only DOM and web APIs.

Cross-browser variation

  • Chrome / Edge: old content scripts stay alive and orphaned after update; the patterns above are necessary.
  • Firefox: typically unloads content scripts when the extension is disabled or updated and removes their listeners, but DOM changes remain; the leftover cleanup in step 4 is still needed.
  • Safari: behaviour varies by version; some updates leave scripts running. Apply the same patterns — they are harmless where unnecessary.

Verification

  1. Open a page with the UI mounted, then reload the extension from chrome://extensions.
  2. Within five seconds, the old UI should disappear (watchdog) — or immediately, if your onInstalled re-injection runs.
  3. Confirm exactly one host remains: document.querySelectorAll("readable-fab").length === 1.
  4. In the Performance panel, record a few seconds of DOM activity and confirm no callbacks from the old instance’s observer appear.
  5. Toggle the feature off in options and confirm it disappears from all open tabs without reload.

FAQ

Can I detect the update before the context is invalidated?

Keep a long-lived port from the content script to the worker; its onDisconnect fires when the extension reloads. It is the fastest signal, at the cost of keeping a port open per tab.

Is chrome.runtime.id access itself safe after invalidation?

Reading it returns undefined in Chrome; wrapping it in try covers engines that throw.

Should teardown restore page styles I changed?

Yes — anything you changed, such as scroll locks or inserted CSS, should be reverted. If you injected CSS with insertCSS, have the worker call removeCSS; the content script cannot.

Other UI/UX Patterns & Interactive Components Resources