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.
Table of Contents
- Root cause: three surfaces have lifetimes long enough to accumulate
- Step 1. Establish that there is a leak before hunting one
- Step 2. Compare heap snapshots across the cycle
- Step 3. Fix detached DOM, the most common finding
- Step 4. Fix listeners and ports that outlive their subject
- Step 5. Make cleanup impossible to skip in content scripts
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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.
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
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; useabout:memoryand 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
- Run the cycle measurement fifty times. A per-cycle delta under a kilobyte is noise; a consistent tens-of-kilobytes delta is a leak.
- Take the three snapshots and confirm the suspect objects survive into the third.
- Filter the snapshot for
Detachedand confirm the count is zero after the fix. - Open and close the detail view twenty times, then check the service worker’s connected ports — there should be none.
- 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.
Related
- Measuring content script impact on page load — the CPU half of the same complaint.
- Reducing service worker cold start latency — why an evictable worker is worth protecting.
- Long-lived ports vs one-time messages — the port lifecycle this depends on.
- Performance Profiling & Optimization for MV3 Extensions — the guide this page belongs to.