Memory Profiling
A practical walkthrough of Chrome DevTools memory tooling — Heap Snapshot (Summary vs Comparison vs Containment vs Dominators), Allocation Timeline, Allocation Sampling, the Retainer tree, shallow vs retained size, performance.measureUserAgentSpecificMemory(), and a step-by-step debugging workflow.
Overview
Memory profiling is the process of measuring how your application allocates and retains memory over time. JavaScript's GC handles most memory management automatically — but it can only free memory that is no longer referenced. When objects are unintentionally kept alive, your app grows in memory usage, causing slowdowns or crashes.
Chrome DevTools provides three primary tools to diagnose memory problems, each suited to a different class of question.
How It Works
V8 allocates objects on the heap — a region of memory for dynamically created values. The GC periodically scans the heap and frees objects with no remaining references.
A memory leak happens when a reference to an object is unintentionally preserved, preventing the GC from freeing it. Common culprits: event listeners not removed on unmount, closures capturing large objects, global variables or caches that grow unbounded, and detached DOM nodes still referenced in JavaScript.
The Three DevTools Memory Tools
| Tool | Best for | Overhead |
|---|---|---|
| Heap Snapshot | Point-in-time comparison — finding what survived GC | High (pauses JS) |
| Allocation Timeline | Catching allocations that are never freed as they happen | Medium |
| Allocation Sampling | Identifying which functions allocate the most — safe for near-production | Very low |
Key Heap Snapshot Concepts
Shallow size: Memory the object itself occupies (the binding/wrapper). Typically small.
Retained size: Memory that would be freed if this object were deleted — includes everything reachable only through it. This is the actionable metric for prioritising leak fixes.
Retainer tree: The path from a GC root to the object. Working up the retainer tree reveals which variable, closure, or data structure holds the object alive.
Dominators view: An object A dominates object B if every path from any GC root to B must pass through A. The dominators tree shows which objects are single points of retention — freeing a dominator frees everything it dominates.
Code Examples
Simulating a Memory Leak
// app/components/LeakyComponent.tsx
"use client";
import { useEffect } from "react";
export default function LeakyComponent() {
useEffect(() => {
const largeData = new Array(100_000).fill("leak"); // ~800KB string array
// ❌ Inline arrow function — cannot be removed, captures largeData forever
window.addEventListener("resize", () => {
console.log(largeData.length);
});
// No cleanup returned — listener and largeData live until the tab closes
}, []);
return <div>Leaky component</div>;
}// app/components/HealthyComponent.tsx
"use client";
import { useEffect } from "react";
export default function HealthyComponent() {
useEffect(() => {
const largeData = new Array(100_000).fill("data");
const handleResize = () => {
console.log(largeData.length);
};
window.addEventListener("resize", handleResize);
// ✅ Cleanup removes listener → largeData becomes unreachable → GC collects it
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return <div>Healthy component</div>;
}Step-by-Step: Heap Snapshot Comparison Workflow
── Workflow: Confirm and locate a leak ──────────────────────────────────────
Step 1: Open DevTools → Memory tab
Step 2: Click "Collect garbage" (🗑) to force a GC baseline
Step 3: Take Snapshot 1 (baseline)
→ Records every live object in the heap
Step 4: Perform the suspected leaking action 5–10 times
→ Mount/unmount a component, open/close a modal, navigate back and forth
Step 5: Click "Collect garbage" again
→ Forces a full GC cycle — only genuinely retained objects remain
Step 6: Take Snapshot 2
Step 7: In Snapshot 2 dropdown, switch from "Summary" to "Comparison"
→ Columns: #New, #Deleted, #Delta, Alloc. Size, Freed Size, Size Delta
Step 8: Sort by "+Delta" (net object count increase)
→ Objects with high positive Delta after a forced GC are genuinely leaked
Step 9: Click a suspicious class (e.g., "Closure", "Detached HTMLDivElement")
→ Bottom panel switches to "Retainers"
Step 10: Read the Retainer tree bottom-up
→ The bottom entry is a GC root (window, module export, active closure)
→ That is the reference you need to release
Step 11: Apply the fix, repeat steps 4–8
→ Delta for the previously leaking class should approach 0Heap Snapshot Views
Summary view:
Groups objects by constructor name (e.g., "Array", "Closure", "HTMLDivElement")
→ Use for: exploring what's on the heap, getting an initial inventory
Comparison view:
Diffs two snapshots — shows what was created or deleted between them
→ Use for: confirming a leak exists and identifying which type leaks
Containment view:
A tree of GC roots → their direct properties → nested objects
→ Use for: exploring the object graph from a known root downward
Dominators view:
Shows which objects dominate (are the sole retention path for) other objects
→ Use for: finding the single object you need to release to free a large subtreeAllocation Timeline Workflow
── Workflow: Catch allocations that are never freed ─────────────────────────
Step 1: Memory tab → "Allocation instrumentation on timeline" → Start
Step 2: Interact with your app
→ Navigate routes, open/close modals, trigger data fetches
Step 3: Stop
Step 4: Read the timeline
→ Blue bars = allocations retained (not yet collected)
→ Grey bars = allocations that were freed
Step 5: Click a blue bar
→ Bottom panel shows the allocation stack trace — which function allocated it
→ The constructor name and source location point to the leak siteThe Allocation Timeline is particularly useful for finding leaks from short-lived allocations — objects created and immediately orphaned but somehow kept alive. They appear as persistent blue bars in the timeline even after the action completes.
Allocation Sampling — Low-Overhead Profiling
Allocation sampling has very low overhead and is safe to run against near-production builds. It samples function call stacks during allocation — giving you a flame chart of which functions allocate the most bytes:
Step 1: Memory tab → "Allocation sampling" → Start
Step 2: Interact normally for 30–60 seconds
→ Covers realistic user workflows
Step 3: Stop → view the flame chart
Step 4: The width of each bar represents total bytes allocated by that call path
→ Focus on wide, unexpected bars — these are your allocation hot spots
→ Useful for: "my heap is growing but I can't tell where from"Reading the Retainer Tree
The Retainer tree (bottom panel of any selected object in a heap snapshot) shows the chain from the selected object back to a GC root:
── Example retainer tree for a leaked Closure ──────────────────────────────
Closure ← the leaked object (selected)
in handleResize @ resize-tracker.tsx:12 ← the listener function
in listeners @ Window ← window's event listener list
in Window / <GC root> ← GC root — this is the anchor
Interpretation:
The closure `handleResize` is held alive by `window`'s "listeners" map.
Fix: call window.removeEventListener("resize", handleResize) on unmount.── Example retainer tree for a Detached HTMLDivElement ──────────────────────
Detached HTMLDivElement ← the leaked node
in cachedBanner @ banner-manager.ts:7 ← module-level variable
in module exports / <GC root> ← module is a GC root
Interpretation:
`cachedBanner` at line 7 of banner-manager.ts holds the node.
Fix: set cachedBanner = null when the banner is removed.performance.measureUserAgentSpecificMemory() — Programmatic Memory Measurement
Available in cross-origin-isolated contexts, this API measures live memory usage including Web Workers and iframes:
// app/api/memory/route.ts — expose via a dev-only endpoint
export async function GET() {
if (!("measureUserAgentSpecificMemory" in performance)) {
return new Response("API not available", { status: 503 });
}
try {
// Requires cross-origin isolation:
// COOP: same-origin + COEP: require-corp headers
const result = await (performance as any).measureUserAgentSpecificMemory();
// result.bytes = total JS memory (heap + workers + iframes)
// result.breakdown = per-attribution detail
return Response.json({
totalMB: (result.bytes / 1_048_576).toFixed(2),
breakdown: result.breakdown.map((entry: any) => ({
bytes: entry.bytes,
types: entry.types,
url: entry.attribution?.[0]?.url,
})),
});
} catch (err) {
return Response.json({ error: String(err) }, { status: 500 });
}
}performance.measureUserAgentSpecificMemory() requires your page to be
cross-origin isolated (Cross-Origin-Opener-Policy: same-origin +
Cross-Origin-Embedder-Policy: require-corp). It's intended for monitoring,
not for triggering GC or making runtime decisions.
Real-World Use Case
A dashboard renders and destroys chart components as users switch between tabs. After five tab switches, Chrome Task Manager shows the tab consuming 180MB — up from 40MB on initial load. Two heap snapshots + Comparison: ResizeObserver callback closures are accumulating at +5 per switch. Retainer tree: each closure is held by a ResizeObserver's internal target list. Root cause: the charting library registers a ResizeObserver on its canvas element but doesn't expose a destroy API. Fix: capture the observer instance in useEffect, call observer.disconnect() in the cleanup return. After fix: second comparison snapshot shows zero new ResizeObserver closures across five tab switches. Heap stays stable at ~42MB.
Common Mistakes / Gotchas
1. Taking only one snapshot. A single snapshot shows every object on the heap — mostly framework internals. The comparison between two snapshots isolates what changed. Always take before and after.
2. Forgetting to force GC before snapshotting. The heap fluctuates as V8 pre-allocates and defers collection. Without forcing GC (click 🗑 first), your snapshot includes objects that are already unreachable but haven't been swept yet — false positives in the comparison.
3. Profiling in development mode. React StrictMode double-invokes effects; source maps and dev-mode React internals inflate object counts. Always confirm leaks against a production build (next build && next start).
4. Confusing shallow size with retained size. Shallow size tells you how big the object's own storage is. Retained size tells you the total memory freed if you fixed this leak. Prioritise by retained size.
5. Ignoring the Dominators view for complex object graphs. When a single object dominates a large retained subtree, fixing that one object fixes everything below it. The Dominators view surfaces this; Summary view doesn't.
Summary
Chrome DevTools provides three memory profiling tools with different trade-offs: Heap Snapshot for point-in-time comparison (high overhead, pause-inducing), Allocation Timeline for watching allocations happen live (medium overhead), and Allocation Sampling for low-overhead identification of allocation-heavy call paths (safe for near-production). Always compare two snapshots using the Comparison view — sort by +Delta after forcing GC to isolate genuinely leaked objects. Read the Retainer tree bottom-up to trace the reference chain from the leaked object to its GC root. Prioritise fixes by retained size, not shallow size. Profile against a production build to avoid noise from StrictMode and development-only internals. Use performance.measureUserAgentSpecificMemory() in cross-origin-isolated pages for programmatic production monitoring.
Interview Questions
Q1. What are the three Chrome DevTools memory profiling tools and when should you use each?
Heap Snapshot takes a complete point-in-time photograph of every live object in the JavaScript heap. It pauses JavaScript execution during the snapshot, so it's not appropriate for production use, but it gives the most detailed picture. Use it to compare before/after snapshots and identify classes of objects that accumulate. Allocation Timeline records every allocation as it happens over a time window and shows which allocations were later freed (grey) versus retained (blue). Use it to catch leaks that are hard to reproduce repeatedly — you watch the leak happen in real time. Allocation Sampling samples the call stack during allocations with very low overhead (typically <1% CPU). It produces a flame chart of allocation-heavy call paths. Use it to identify which functions drive the most heap growth in near-production conditions, or when you can't afford Heap Snapshot's pause time.
Q2. What is the difference between shallow size and retained size, and which should you sort by when investigating a leak?
Shallow size is the memory occupied by the object itself — its own properties and internal fields, excluding anything it references. For most wrapper objects (JS bindings to native objects, simple class instances), this is a few hundred bytes. Retained size is the memory that would become freeable if this object were collected — it includes everything reachable only through this object with no other path to a GC root. A Map with 10,000 entries has a small shallow size (its own Map structure) but potentially a huge retained size (all 10,000 keys and values that would be freed if the Map were released). Sort by retained size when investigating leaks — it tells you the actual memory impact of fixing each candidate. Sorting by count or shallow size often surfaces framework internals rather than actionable leak sources.
Q3. How do you read the Retainer tree in a heap snapshot, and what does it tell you?
The Retainer tree shows the reference chain from the currently selected object back to a GC root. Each row is an object that holds a reference to the row above it — you read it bottom-up: the bottom entry is the GC root (a window property, a module export, an active closure in a running timer). Working upward shows you each link in the chain: "this property of this object references this function which is held by this event listener which is registered on window." The terminal GC root tells you where to add cleanup: if the retainer chain ends at window.addEventListener, you need a removeEventListener call. If it ends at a module-level variable, you need to set it to null. The retainer tree is the most direct path from "I know something is leaking" to "I know exactly what code to fix."
Q4. What is the Dominators view and when is it more useful than the Summary view?
An object A dominates object B if every path from any GC root to B must pass through A — A is the only route by which B is retained. The Dominators view groups objects under their dominator, showing the total retained size that would be freed by releasing each dominator. It's more useful than Summary when you have a complex object graph with many interconnected objects — Summary shows them individually, which can be misleading. With the Dominators view, if one Map instance dominates 500 other objects, it appears at the top with a retained size reflecting all 500 dependents. Fixing that one object fixes all 500. For simple leaks (a single listener or a module variable), Summary + Comparison is sufficient. For architectural leaks with deep retention trees, Dominators reveals the single highest-leverage fix.
Q5. Why must you force GC before taking a heap snapshot, and what happens if you skip this step?
V8 collects garbage opportunistically — it doesn't run GC after every object becomes unreachable; it batches collection based on heap pressure and internal heuristics. At any given moment, the heap contains both live objects and already-unreachable objects that V8 simply hasn't swept yet. If you take a snapshot without forcing GC first, these pending-collection objects appear in the snapshot as live. When you then compare snapshots, these objects show up in the +Delta column as apparent survivors of your leaking action — false positives. You investigate them, find they have no meaningful retainer chain (because they're actually unreachable), and waste time. Clicking the "Collect garbage" 🗑 icon triggers a full major GC cycle before the snapshot, ensuring that only genuinely retained (truly leaked) objects appear in the snapshot.
Q6. What is performance.measureUserAgentSpecificMemory(), what does it measure that DevTools snapshots don't, and what does it require?
performance.measureUserAgentSpecificMemory() is a browser API that returns a bytes total for all JavaScript memory consumed by the page — including the main thread heap, dedicated Web Workers, shared workers, and iframes from the same origin. DevTools heap snapshots measure only the main thread's V8 heap; they miss memory used in workers and cross-frame scripts. The API also returns a breakdown array with per-attribution detail (origin, URL) for each memory contributor. It's designed for programmatic monitoring — you can call it periodically and report to an observability platform. Requirements: the page must be cross-origin isolated, meaning it must serve Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers. Without these, the browser returns a security error because the API could otherwise be used as a timing side-channel to infer data from cross-origin iframes.
Detached DOM Nodes
What detached DOM nodes are, how they accumulate in SPAs, the six patterns that create them — module-level variables, event listener closures, uncleaned React effects, observer callbacks, Map-keyed caches, and subtree retention — and how to find and fix each one.
Overview
Building UIs that work for all users — the browser and AT mechanics underneath ARIA, focus management, keyboard patterns, and visual accessibility.