Profiling Memory Leaks in Long-Lived Extension Pages

Find what a side panel, options tab or content script keeps alive: heap snapshot comparison, detached DOM, listener and port leaks, and cleanup that actually runs.

Published August 1, 2026 Updated August 1, 2026 9 min read
Table of Contents

The popup cannot leak — it is destroyed every time it closes. The side panel, the options tab and every content script on a page the user leaves open all day absolutely can, and they leak in a way that is easy to miss because the extension is the smallest process on the machine right up until it is not. This page is part of Performance Profiling & Optimization for MV3 Extensions and covers finding the retainer, not guessing at it.

Root cause: three surfaces have lifetimes long enough to accumulate

Manifest V3’s evictable worker made most background leaks self-correcting — the process dies every thirty seconds and takes the leak with it. What is left are the surfaces that stay: an open side panel, an options tab, and a content script on a long-lived page. Each one accumulates through the same three mechanisms.

Which surfaces can leak, and through whatEach extension surface compared on lifetime and the retention mechanisms that apply to it.SurfaceTypical lifetimeDetached DOMListeners / portsPopupSecondsCleared on closeCleared on closeService worker~30 s idleNo DOMCleared on evictionSide panelHoursReal riskReal riskOptions tabHoursReal riskReal riskContent scriptPage lifetimeReal riskReal risk
The worker column is mostly empty because eviction cleans up after you — the others have no such safety net.

Step 1. Establish that there is a leak before hunting one

Memory that grows and then plateaus is a cache. Memory that grows without bound across a repeated cycle is a leak. Measure the cycle.

Reading the shape of the growth before hunting a retainerGrowth that plateaus is a cache, growth that persists after idle is a leak, and growth that disappears after collection was never real.What does memory do across fifty repeated cycles?Rises then flattensA cachebounded by designCheck the bound is sanethen stopDrops after idleDelayed collectionnot a leakRe-measure after a forced GCthen stopRises linearlyA genuine leaksteady KB per cycleGo to the heap snapshotsfind the retainer path
Two of the three branches end the investigation immediately — run this first.
 1// dev-only, loaded into the side panel during investigation
 2async function sample(label) {
 3  // performance.measureUserAgentSpecificMemory needs cross-origin isolation in web pages,
 4  // but extension pages are already isolated, so it is available directly.
 5  const result = await performance.measureUserAgentSpecificMemory?.();
 6  const bytes = result?.bytes ?? performance.memory?.usedJSHeapSize ?? 0;
 7  console.log(label, (bytes / 1e6).toFixed(1), "MB");
 8  return bytes;
 9}
10
11const before = await sample("before 50 cycles");
12for (let i = 0; i < 50; i++) await openAndCloseDetailView();
13await new Promise((r) => setTimeout(r, 2000));      // let GC settle
14const after = await sample("after 50 cycles");
15console.log("delta per cycle:", ((after - before) / 50 / 1024).toFixed(1), "KB");

Execution context: Side panel or options page document, in a development build only. measureUserAgentSpecificMemory is available in Chrome and Edge and returns a settled measurement after a garbage collection, which is far more trustworthy than performance.memory — that one reports the heap at an arbitrary moment and swings by megabytes between calls. Firefox implements neither; use about:memory there and take the measurement manually. A steady delta per cycle of a few kilobytes is the signature you are looking for.

Step 2. Compare heap snapshots across the cycle

The three-snapshot comparisonTake a baseline, run the cycle several times, take a second snapshot and diff it, then confirm with a third that the growth is not a delayed collection.YouDevToolsSide panelHeapSnapshot 1 (baseline)open and close the view ×20Snapshot 2compare: objects allocated between 1 and 2and still aliveSnapshot 3 after idlestill alive in 3 → genuine leak
The third snapshot is what separates a leak from a garbage collector that had not run yet.

Open the side panel’s own DevTools — right-click inside the panel and choose Inspect, which gives you a window scoped to that document rather than to the page. In the Memory panel, take a snapshot, run the cycle twenty times, take a second, then switch the comparison dropdown to “Objects allocated between Snapshot 1 and Snapshot 2”. Sort by retained size. The three things you are looking for are Detached HTMLElement entries, closures retained by an event listener, and arrays that grow by exactly the number of cycles you ran.

Step 3. Fix detached DOM, the most common finding

A node removed from the document but still referenced by your code keeps its entire subtree alive.

 1// Before: the map keeps every row that was ever rendered.
 2const rowIndex = new Map();
 3
 4function render(items) {
 5  list.replaceChildren();                     // nodes leave the document…
 6  for (const item of items) {
 7    const row = buildRow(item);
 8    rowIndex.set(item.id, row);               // …but the map still holds them
 9    list.append(row);
10  }
11}
12
13// After: rebuild the index each render, so the old nodes have no references left.
14let rowIndex = new Map();
15
16function render(items) {
17  const next = new Map();
18  const rows = items.map((item) => {
19    const row = buildRow(item);
20    next.set(item.id, row);
21    return row;
22  });
23  list.replaceChildren(...rows);
24  rowIndex = next;                            // the previous map, and its nodes, become garbage
25}

Execution context: Side panel or options page. replaceChildren removes the old nodes from the document but does nothing about references your own code holds — the map is the retainer, not the DOM. A WeakMap keyed by the node would also work, but only if nothing else holds the node; keying by id and rebuilding is easier to reason about. The heap snapshot names this exactly: filter for Detached and the retainer path shows the map.

Step 4. Fix listeners and ports that outlive their subject

Every addEventListener on a target that outlives the listener’s owner is a retention edge. In an extension the long-lived targets are document, window, chrome.storage.onChanged and any open port.

 1// panel.js — one disposer, called from one place
 2function mountDetailView(item) {
 3  const controller = new AbortController();
 4  const { signal } = controller;
 5
 6  document.addEventListener("keydown", onKey, { signal });
 7  window.addEventListener("resize", onResize, { signal });
 8
 9  const port = chrome.runtime.connect({ name: `detail:${item.id}` });
10  port.onMessage.addListener(onPortMessage);
11
12  const observer = new ResizeObserver(onResize);
13  observer.observe(panelEl);
14
15  return function unmount() {
16    controller.abort();                 // removes both DOM listeners at once
17    port.onMessage.removeListener(onPortMessage);
18    port.disconnect();                  // and lets the worker drop its side
19    observer.disconnect();
20  };
21}

Execution context: Side panel document. AbortController with the signal option is the cleanest way to remove DOM listeners in bulk and is supported in every engine that supports Manifest V3. The port is the one that also leaks on the other side: an undisconnected port keeps a reference in the service worker and, worse, keeps the worker itself alive, which is why a panel that opens ports without closing them prevents the extension from ever going idle — and therefore prevents updates from applying, as described in controlling when an update is applied. chrome.storage.onChanged listeners must be removed explicitly; there is no signal option for extension events.

Step 5. Make cleanup impossible to skip in content scripts

A content script on a single-page application must unmount when the page navigates within itself, and there is no unload event that reliably fires.

 1// content.js
 2const disposers = new Set();
 3export function track(dispose) { disposers.add(dispose); return dispose; }
 4
 5function teardown() {
 6  for (const dispose of disposers) {
 7    try { dispose(); } catch { /* the page may have removed the node already */ }
 8  }
 9  disposers.clear();
10}
11
12// Same-document navigation in a single-page app.
13navigation?.addEventListener("navigate", teardown);
14// Classic fallback for engines without the Navigation API.
15window.addEventListener("pagehide", teardown);
16// And when the extension itself goes away.
17document.addEventListener("ext-retire", teardown);

Execution context: Content script, isolated world. Three triggers because no single one covers every case: the Navigation API fires on in-page routing and is Chromium-only, pagehide fires on real navigation and back/forward cache eviction in all engines, and the retirement event covers an extension update orphaning the script — the mechanism described in fixing extension context invalidated after an update. Registering disposers as you create things, rather than writing a teardown function that tries to remember, is what keeps the two in step as the script grows.

Cross-browser variation

  • Chrome/Edge: measureUserAgentSpecificMemory, heap snapshots per extension document, and a Detached-node filter in the Memory panel. Inspecting the side panel gives it its own DevTools window.
  • Firefox: no measureUserAgentSpecificMemory; use about:memory and its “Measure and save” button, which reports per-add-on figures. The Memory tool supports snapshot diffing with a dominators view.
  • Safari: the Web Inspector’s Timelines include allocations, but extension pages are harder to reach — inspect through Develop, Web Extension Background Content and the page menu. Expect less tooling and lean more on the cycle measurement in step 1.
  • All engines: an open port keeps the background context alive. This is the leak with the largest user-visible consequence, because it changes the extension’s whole lifecycle rather than just its memory.

Verification

  1. Run the cycle measurement fifty times. A per-cycle delta under a kilobyte is noise; a consistent tens-of-kilobytes delta is a leak.
  2. Take the three snapshots and confirm the suspect objects survive into the third.
  3. Filter the snapshot for Detached and confirm the count is zero after the fix.
  4. Open and close the detail view twenty times, then check the service worker’s connected ports — there should be none.
  5. Leave the side panel open for an hour with the extension idle and confirm the worker is evicted, which proves nothing is holding it open.

FAQ

Does the service worker need any of this?

Rarely. Eviction collects everything every thirty seconds. The exception is a worker held alive by an open port, which then behaves like a long-lived page.

Is performance.memory good enough?

For a rough trend, yes. It is unsettled and quantised, so never use it for a per-cycle delta — use measureUserAgentSpecificMemory where available.

Why do I see detached nodes right after a render?

Because collection has not run yet. Take the snapshot after an idle period, or use the collect-garbage button in the Memory panel first.

Can a leak in a content script affect the page?

Yes — it is the same process. A content script holding a few thousand detached nodes makes the page’s own memory profile worse, which is what users notice.

Other Testing, Debugging & Performance Optimization Resources