Reading Allocation Timelines to Identify Memory Leaks

The heap is growing after every modal open, every route change, every scroll burst — and a static snapshot does not show why. This page explains how to read the allocation timeline view in Using Allocation Timelines to Track Object Creation, part of the broader Browser DevTools & Performance Profiling Workflows reference, to isolate the exact constructor calls causing retention before the problem reaches production.

Symptom-to-Fix Diagnostic Matrix

Start here if you have a live incident. The table maps the symptom you see on screen to the mechanism responsible and the first action to take.

Symptom Root Cause Immediate Action Measurable Impact
Blue bars grow with every repeated interaction Event listeners or observers registered but never removed DevTools → Memory → Allocation instrumentation on timeline → filter by EventListener constructor; inspect stack for missing removeEventListener Heap delta drops from +10–20 MB/cycle to ≤ +0.1 MB/cycle after cleanup
Blue Array or Object bars accumulate inside a closure Callback capturing a large object graph via lexical scope Expand Allocation Stack, locate the enclosing function; replace closure capture with a WeakRef or pass only the values needed Retained size of the constructor falls to 0 KB post-fix
Timeline shows thousands of short-lived gray bars then one large blue spike Promise chain or async/await accumulating microtask queue backlog DevTools → Memory → Allocation instrumentation on timeline → filter Promise; check for unresolved promise chains blocking GC GC pause duration drops from >80 ms to <15 ms; promise constructor count stabilises
Heap does not recover after forced GC (trash-can icon) Detached DOM nodes holding subtrees in memory DevTools → Memory → Heap Snapshot → Comparison view; filter Detached in class filter Retained size from detached nodes drops to 0 bytes; GC can collect the subtree
Timeline empty — no bars appear Allocation instrumentation on timeline not selected; source maps absent Memory panel → select Allocation instrumentation on timeline (not Heap snapshot); Settings → Enable JavaScript source maps Stack traces appear on next recording
Blue bars plateau and then suddenly drop Intentional LRU cache eviction — not a leak Confirm with five-cycle measurement; eviction events correlate with cache.delete() or TTL expiry in stack trace No action required; variance ≤ 2% across cycles confirms bounded growth

Root Cause Explanation

The allocation timeline records every object creation event in chronological order, attaching a V8 stack trace sample to each allocation site. The key insight is the colour encoding: blue bars represent objects still reachable (retained) at the moment you are viewing the recording; gray bars represent objects that were allocated and later collected by the mark-and-sweep garbage collector.

When the garbage collector runs a collection cycle, it walks the object graph from GC roots (the global object, the call stack, native handles) outward. Any object reachable via at least one reference chain survives; unreachable objects are swept and their memory reclaimed. A memory leak is therefore not about allocation volume — it is about inadvertent reachability: an object that should be ephemeral is kept alive by a reference the developer forgot to sever.

The timeline exposes this by persisting blue bars across time. If you open a modal, close it, force GC (DevTools → Memory panel → trash-can icon), and still see blue HTMLDivElement or Array bars that were allocated during the modal’s lifecycle, those objects are still reachable from a root — typically a global event listener, a timer callback, or a closure capturing a large object graph.

V8 complicates this picture through two mechanisms. First, it uses a generational heap: newly allocated objects land in the young generation (new-space, typically 1–8 MB) and are promoted to old-space only after surviving one or two minor GC cycles. A single recording may therefore show objects that have not yet been promoted and collected, even when no real leak exists. Run at least three identical interaction cycles before concluding that blue bars represent leaks. Second, V8’s escape analysis can prove that certain allocations never leave a function’s scope and optimise them away entirely — they will not appear in the timeline. If a constructor you expect to see is absent, launch Chrome with --js-flags="--no-opt" in development to disable JIT compilation and surface those hidden allocation sites.

The allocation timeline integrates with interpreting heap snapshots for memory analysis as a complementary tool: timelines locate when a leak starts; snapshots reveal what is holding the retained objects alive through the dominator tree.


Step-by-Step Fix

Follow these steps in order. Each step includes the exact DevTools path and an expected output you can verify before proceeding.

Step 1 — Open the correct profiling mode

  • Open DevTools: Cmd+Opt+I (macOS) or Ctrl+Shift+I (Windows/Linux).
  • Navigate to: DevTools → Memory → Allocation instrumentation on timeline.
  • Verify source maps are active: DevTools → Settings (gear icon) → Preferences → Enable JavaScript source maps.
  • Launch Chrome with --disable-extensions or profile in Incognito to eliminate extension noise.

Expected output: The Memory panel shows a blank timeline bar chart, ready to record. The “Record allocation stacks” checkbox is enabled.

Step 2 — Establish a clean baseline

  • Click the trash-can (Force garbage collection) icon.
  • Note the JS Heap size shown in the panel header (example: 42.1 MB).
  • Wait 2–3 seconds for any lazy-GC collection to complete; the number should stabilise.

Expected output: Heap size is stable and matches your ambient application memory. Any subsequent growth is attributable to the recorded interaction.

Step 3 — Record the target interaction

  • Click the circle (record) button.
  • Execute the suspect flow exactly once: open and close a modal, navigate a route, trigger a scroll burst.
  • Stop recording immediately. Background tasks (analytics, idle callbacks, requestIdleCallback jobs) inflate the timeline if recording runs too long. Cap sessions at 15–30 seconds.

Expected output: The timeline populates with blue and gray bars grouped by constructor name.

Step 4 — Triage blue bars

  • In the constructor list, sort by “Retained Size” descending.
  • Filter to application-specific types: type a constructor name (e.g., EventListener, HTMLDivElement, MyComponent) in the class filter field.
  • Toggle “Hide native functions” in the stack trace panel to suppress V8 internals.
  • Repeat the interaction cycle two more times without clearing the recording. Leak indicator: blue bars for the same constructor keep growing linearly. Normal indicator: blue bars plateau after the first cycle as V8 promotes survivors.

Expected output: One or more constructors show monotonically increasing blue-bar retained sizes across cycles — these are the leak candidates.

Step 5 — Expand the allocation stack

  • Click a blue bar segment for a candidate constructor.
  • In the “Allocation Stack” panel below, expand the call frames.
  • Click a frame to jump to the exact line in the Sources panel.
  • Identify the retention anchor from the call site category:
    • Framework lifecycle: missing useEffect cleanup return, omitted componentWillUnmount.
    • Timer: uncleared setInterval or setTimeout reference.
    • Observer: ResizeObserver, IntersectionObserver, or MutationObserver never disconnected.
    • Event listener: addEventListener without a paired removeEventListener or AbortController.

Expected output: You can read the exact file, function, and line number where the object was allocated and identify why it remains reachable.

Step 6 — Apply the fix and verify

  • Implement the cleanup (see code reference section below).
  • Clear the recording (trash-can icon).
  • Repeat steps 2–4 for three cycles.
  • Pass criteria: heap delta ≤ +0.1 MB per cycle; blue bars for the patched constructor return to 0 KB retained; GC pause durations drop from >80 ms to <15 ms.

Runnable Code Reference

Use-case: Event listener accumulation — leak and fix

The leak below registers a resize handler that closes over a large array on every call, with no mechanism to remove the listener.

// LEAK: resize handler registered on every component mount with no cleanup.
// Each call creates a new listener closure holding 10 000 objects in scope.
function mountComponent() {
  window.addEventListener('resize', () => {
    // heavyData is captured in the closure — it stays alive as long as
    // the listener is registered, which is forever in this pattern.
    const heavyData = new Array(10_000).fill({ value: Math.random() });
    renderLayout(heavyData);
  });
}

Fix using AbortController so the listener and its closure scope are released atomically on unmount.

// FIX: AbortController ties listener lifetime to component lifetime.
// When controller.abort() is called, the listener and its closure are released.
function mountComponent() {
  const controller = new AbortController();

  window.addEventListener(
    'resize',
    () => {
      // heavyData is still created per event, but the listener itself
      // can now be fully removed — no retained closure after abort().
      const heavyData = new Array(10_000).fill({ value: Math.random() });
      renderLayout(heavyData);
    },
    { signal: controller.signal } // browser removes listener when signal fires
  );

  // Return cleanup so the caller (framework hook, router, etc.) can call it.
  return () => controller.abort();
}

Timeline before fix: +14.2 MB blue bars per 100 resize events; GC pauses 110 ms; heap never falls below 156 MB.

Timeline after fix: allocations shift to gray bars within one GC cycle after abort(); heap delta +0.1 MB/cycle; GC pauses 12 ms.

Use-case: Observer leak — ResizeObserver never disconnected

// LEAK: ResizeObserver is created on every render but observe() is never
// paired with disconnect(). Each observer holds a strong reference to its
// target element, preventing GC of the element subtree.
function attachObserver(element) {
  const observer = new ResizeObserver((entries) => {
    entries.forEach((entry) => updateDimensions(entry.contentRect));
  });
  observer.observe(element); // starts observing — never stopped
}

// FIX: return a cleanup function that calls disconnect() when the component
// unmounts or the element is removed from the DOM.
function attachObserver(element) {
  const observer = new ResizeObserver((entries) => {
    entries.forEach((entry) => updateDimensions(entry.contentRect));
  });
  observer.observe(element);

  // Caller must invoke cleanup() on unmount / route change.
  return function cleanup() {
    observer.disconnect(); // releases the element reference held by the observer
  };
}

Inline SVG: Allocation Timeline Colour Lifecycle

The diagram below shows the lifecycle of allocated objects as they appear in the timeline recording window.

Allocation Timeline Colour Lifecycle Diagram showing two paths an allocated object can take: short-lived objects become gray bars after garbage collection; long-lived or leaked objects stay blue (retained). Blue bars accumulating across repeated interaction cycles indicate a memory leak. Recording time → Object created GC runs, no root path Gray bar Collected — expected Root still holds ref Blue bar Retained — investigate repeats Blue bars grow linearly → confirmed leak after fix Bars turn gray GC collects — leak fixed

Verification and Regression Prevention

Confirming the fix

After applying cleanup logic, verify using this checklist:

  1. DevTools → Memory panel → trash-can (force GC) → note baseline heap size.
  2. Record three identical interaction cycles in a single session.
  3. After each cycle, check the blue-bar retained size for the patched constructor: target is 0 KB or a value that does not grow cycle-over-cycle.
  4. Heap delta per cycle: ≤ +0.1 MB pass; > +1 MB investigate further.
  5. GC pause durations (visible in DevTools → Performance panel → Experience row): target < 15 ms; > 50 ms indicates significant retained pressure remains.

Regression prevention

Automated heap assertion in CI (Node.js / Puppeteer):

// Use-case: Puppeteer-based heap regression test — run in CI after each build.
// Fails the test suite if a route change leaks more than the threshold.
import puppeteer from 'puppeteer';

const LEAK_THRESHOLD_BYTES = 2 * 1024 * 1024; // 2 MB — adjust to your baseline

async function assertNoHeapGrowthOnRouteChange(url) {
  const browser = await puppeteer.launch({
    args: ['--enable-precise-memory-info'], // byte-accurate heap reporting
  });
  const page = await browser.newPage();
  await page.goto(url);

  const client = await page.target().createCDPSession();

  // Force GC before capturing the baseline to flush transient allocations.
  await client.send('HeapProfiler.collectGarbage');
  const { usedSize: before } = (await client.send('Runtime.getHeapUsage'));

  // Simulate the interaction under test.
  await page.click('[data-testid="open-modal"]');
  await page.click('[data-testid="close-modal"]');
  await client.send('HeapProfiler.collectGarbage'); // force collection post-interaction

  const { usedSize: after } = (await client.send('Runtime.getHeapUsage'));
  const delta = after - before;

  await browser.close();

  if (delta > LEAK_THRESHOLD_BYTES) {
    throw new Error(
      `Heap grew by ${(delta / 1024 / 1024).toFixed(2)} MB — exceeds ${LEAK_THRESHOLD_BYTES / 1024 / 1024} MB threshold`
    );
  }
}

ESLint rule to catch missing listener cleanup at authoring time:

Add eslint-plugin-react-hooks (for React projects) and enable the exhaustive-deps rule. For vanilla JS, use eslint-plugin-no-jquery or a custom rule that flags addEventListener calls not paired with a corresponding removeEventListener or AbortController signal in the same function scope.


FAQ

Why are my allocation timelines empty or missing stack traces?

Ensure Allocation instrumentation on timeline is selected in the Memory panel — not Heap snapshot and not Allocation sampling. Open Memory panel settings and confirm Record allocation stacks is checked. Verify source maps load by checking DevTools → Sources → Page: minified filenames without .map equivalents mean stacks will show compiled column offsets rather than readable function names. V8 escape analysis can eliminate allocations entirely from the timeline; run Chrome with --js-flags="--no-opt" in development to disable JIT optimisations that mask allocation sites (this severely impacts runtime performance — do not use in production).

How do I distinguish a real leak from expected cache growth?

Expected caches exhibit bounded growth with periodic eviction. Their blue bars plateau after the initial warm-up cycle and occasionally shrink when the cache evicts entries. Memory leaks grow linearly or exponentially with no plateau and no shrink events. Measure retained size across five identical cycles: variance ≤ 2% indicates a bounded cache; variance > 10% confirms a leak. You can also inspect the stack trace of the growing constructor — if it traces to a cache.set() or WeakMap.set() call with a corresponding cache.delete() or TTL eviction elsewhere, it is intentional.

What V8 launch flags improve timeline accuracy during debugging?

Launch Chrome with --js-flags="--trace-gc" to emit explicit GC events alongside allocation data, making it easy to correlate GC pauses with heap growth. Add --enable-precise-memory-info for byte-accurate heap size reporting (without it, performance.memory.usedJSHeapSize is quantised for fingerprinting resistance). Use --js-flags="--no-opt" only when you specifically need to see allocations the JIT would otherwise optimise away — it makes the application run 3–10x slower and is unsuitable for any performance measurement other than allocation site discovery.