To find an observer leak, take a heap snapshot after warming up, repeat the suspect navigation a fixed number of times, take a second snapshot, and use the Comparison view: observer instances, detached elements or closures whose count grows by exactly the number of repetitions are leaking — their retainer path tells you who is holding them.
Problem / Scenario Context
A single-page admin app gets slower the longer it stays open. Support reports that after an hour of switching between the Orders and Customers views, scrolling stutters and the tab uses over a gigabyte of memory. Each view mounts dozens of components that create observers for lazy images, sticky table headers and column resizing. Everyone suspects a leak; nobody can point to it.
Heap snapshot diffing turns a suspicion into an object count with a retainer chain. The general workflow is in Profiling Observer Performance in DevTools; the leak patterns themselves are catalogued in preventing memory leaks in long-running observers.
Mechanics Explanation
An observer keeps two kinds of things alive:
- Its targets. While an element is observed, the observer holds a strong internal reference to it. A component that is removed from the DOM without
unobserve()ordisconnect()leaves a detached element that cannot be collected — and with it, the element's whole subtree and any data attached to it. - Its callback closure. The callback captures its lexical scope: component instances, stores, large arrays. As long as the observer lives, so does everything the closure captured.
And the observer itself is kept alive by whatever references it: a module-level variable, a component field, a Map in a pool — and, importantly, by its own observed targets, since the browser needs to keep observers with live targets working. An observer whose targets are all still in the document stays alive even if your code has forgotten it.
A heap snapshot records every object and every reference. Comparing two snapshots shows objects allocated between them that are still alive; with a known number of repetitions, a leak shows up as a count that is an exact multiple.
Comparison Table: What the Diff Tells You
| Pattern in Comparison view | Likely cause | Where to look |
|---|---|---|
IntersectionObserver +N per N navigations |
observer created per mount, never disconnected | component teardown |
ResizeObserver stable, Detached HTMLDivElement +many |
shared observer never unobserves removed targets | pool or registry cleanup |
IntersectionObserverEntry +thousands |
entries stored in arrays or state | callback storing entries |
(closure) +N with observer in retainers |
callback capturing component scope | callback definition |
MutationObserver +N |
observing document.body per mount |
global observers created in components |
| Nothing grows; memory still rises | not an observer leak | caches, listeners, timers |
Minimal Reproducible Example
// A view that leaks: observes rows but never cleans up.
function mountOrders(root: HTMLElement): void {
const rows = loadRows(); // big array
const ro = new ResizeObserver(() => layoutColumns(root, rows)); // closure captures rows
root.querySelectorAll('th').forEach((th) => ro.observe(th));
// no return, no disconnect
}
declare function loadRows(): unknown[];
declare function layoutColumns(root: HTMLElement, rows: unknown[]): void;
Navigate between two views that call functions like this and memory climbs with each round trip.
Production-Safe Solution
The diffing procedure
- Open the Memory panel and choose Heap snapshot.
- Warm up. Perform the navigation cycle (Orders → Customers → Orders) twice, so lazy-initialised caches exist before the baseline.
- Snapshot 1. Click the collect-garbage (trash can) button, then take a snapshot.
- Repeat the cycle exactly N times — use an odd, distinctive number such as 7.
- Snapshot 2. Collect garbage, then take another snapshot.
- Comparison view. Select snapshot 2 and switch the view to Comparison against snapshot 1. Sort by # Delta.
- Filter. Type
Observerin the class filter, thenDetached. Counts with a delta of 7 (or a multiple of 7) are your leaks. - Follow the retainers. Select one leaked object; the Retainers pane shows the path from a GC root. The first object in that path that belongs to your code is where the reference must be dropped.
The fix
export function mountOrders(root: HTMLElement): () => void {
const rows = loadRows();
const ro = new ResizeObserver(() => layoutColumns(root, rows));
root.querySelectorAll('th').forEach((th) => ro.observe(th));
return () => ro.disconnect(); // the view's teardown must call this
}
For shared observers, the fix is on the pool side: unobserve each target when its component unmounts, and drop the pool entry when its last target goes, as described in disconnecting pooled observers on route change.
Confirming the fix
Repeat the same procedure. After the fix, the delta for observer classes should be 0 and detached elements should not grow with N. Record the before and after deltas in the pull request.
Automating the Check
Manual diffing finds a leak once; an automated test keeps it fixed. Puppeteer and Playwright can drive the navigation and query the heap through the DevTools protocol:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('http://localhost:3000/orders');
async function countObservers(): Promise<number> {
const session = await page.createCDPSession();
await session.send('HeapProfiler.collectGarbage');
const proto = await page.evaluateHandle(() => ResizeObserver.prototype);
const instances = await page.queryObjects(proto); // live instances only
const n = await page.evaluate((arr) => (arr as unknown[]).length, instances);
await instances.dispose(); await proto.dispose();
return n;
}
const before = await countObservers();
for (let i = 0; i < 7; i++) {
await page.click('a[href="/customers"]'); await page.waitForSelector('#customers');
await page.click('a[href="/orders"]'); await page.waitForSelector('#orders');
}
const after = await countObservers();
if (after > before) throw new Error(`ResizeObserver leak: ${before} → ${after}`);
await browser.close();
page.queryObjects returns every live object with the given prototype, after garbage collection — a direct count without parsing a snapshot.
Run it for each observer class your views use — IntersectionObserver, ResizeObserver and MutationObserver — and for HTMLElement counts if a shared pool is involved, since a pool that forgets to unobserve leaks elements rather than observer instances. Keep the repetition count modest (five to ten cycles) so the test stays fast, and fail on any growth rather than on a threshold: observer counts in a correct app are exactly stable.
Verification Steps
- Use a distinctive repetition count so leak deltas stand out from noise.
- Always collect garbage before each snapshot.
- Check both observers and detached elements; shared observers leak elements, not instances.
- Re-run after the fix and confirm deltas of zero.
- Add the automated count to CI for the views that were leaking.
Common Mistakes to Avoid
- Snapshotting without warm-up. First-visit caches look like leaks.
- Reading the Summary view instead of Comparison. Totals include everything that legitimately exists.
- Stopping at the observer. The observer is often retained correctly; the bug is a target that was never unobserved.
- Keeping DevTools' console references. Objects logged to the console are retained by it; clear the console before snapshotting.
FAQ
Does an observer keep itself alive if my code drops every reference to it?
It can, as long as it still has targets that are connected to the document, because the browser must keep delivering notifications for them. Once its targets are gone or unobserved and nothing references it, it can be collected.
Why do detached elements show up even though I called disconnect?
Something else is still referencing them: an event listener on a global object, a closure in a cache, a framework's dev-mode tracking, or the DevTools console. Follow the retainer path of one detached element to find it.
Is WeakMap enough to avoid observer leaks?
It prevents your registry from keeping elements alive, but it does not stop the observer from doing so. You still need to unobserve targets when they are removed. See the WeakMap registries topic for the pattern that combines both.
How many repetitions should I use?
Enough to rise clearly above noise, and a number that is easy to recognise — 7 or 13 work well. Leak deltas are then exact multiples, which distinguishes them from objects that vary randomly between snapshots.
Related
- Using FinalizationRegistry to Audit Observer Cleanup — leak detection in running code
- Observer Pool Memory Profile in Single-Page Apps — a pool under the same lens
- Preventing Memory Leaks in Long-Running Observers — the patterns behind the findings