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() or disconnect() 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.

A Typical Observer Retainer ChainFour boxes from the root to the leaked data. A module-level pool Map retains an observer instance. The observer's internal target list retains a detached table element. The detached element retains its subtree and attached data, including the component's rows array of five thousand objects.Module-level Mapthe pool, never prunedResizeObserverstill referenced by thepoolDetached tableobserved, neverunobservedRows array5,000 objects keptalive

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

TypeScript
// 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

  1. Open the Memory panel and choose Heap snapshot.
  2. Warm up. Perform the navigation cycle (Orders → Customers → Orders) twice, so lazy-initialised caches exist before the baseline.
  3. Snapshot 1. Click the collect-garbage (trash can) button, then take a snapshot.
  4. Repeat the cycle exactly N times — use an odd, distinctive number such as 7.
  5. Snapshot 2. Collect garbage, then take another snapshot.
  6. Comparison view. Select snapshot 2 and switch the view to Comparison against snapshot 1. Sort by # Delta.
  7. Filter. Type Observer in the class filter, then Detached. Counts with a delta of 7 (or a multiple of 7) are your leaks.
  8. 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

TypeScript
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.

Instance Deltas After Seven Navigation CyclesA bar chart of the Comparison view deltas after seven navigation cycles. Before the fix, ResizeObserver instances grew by seven, detached table elements by seven, and closures by seven. After the fix all three deltas are zero.# Delta after 7 Orders ↔ Customers cyclesResizeObserver, before+7Detached HTMLTableElement, before+7closures, before+7all three, after the fix0

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:

TypeScript
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.

The Leak Test in CIFour steps. Load the app and warm up by visiting the suspect views once. Force garbage collection and count live observer instances. Cycle between the views a fixed number of times. Force garbage collection again, count again, and fail the build if the count grew.1Warm upVisit each suspect view once so lazy caches exist.2Baseline countcollectGarbage, then queryObjects for each observer prototype.3Cycle N timesNavigate between the views with waits for each to render.4CompareCollect and count again; any growth fails the build.

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.


↑ Back to Profiling Observer Performance in DevTools