ESC

Type to search the knowledge base.

Memory Profiling Basics

Heap snapshots, allocation timelines, detached DOM nodes, and practical leak hunts in SPAs.

advanced3 min read
  • browser
  • memory
  • devtools
  • leaks
  • performance

Memory bugs feel like “the tab gets slow after an hour.” CPU profiles won’t show a classic leak; you need the Memory panel: heap snapshots, allocation instrumentation, and an eye for detached DOM nodes retained by closures.

Docs: Chrome Memory panel, MDN memory management.

Symptoms vs causes

Symptom Possible cause
Heap climbs with SPA navigations Listeners/caches not cleared
Detached nodes retain MB React roots, maps of elements, closures
Sudden spikes Large arrays, images, JSON
GC thrash / long pauses Allocation churn

Heap snapshot workflow

  1. Open Memory → Heap snapshot.
  2. Interact (open modal, navigate route, close).
  3. Take another snapshot.
  4. Compare delta / filter Detached.
// Classic leak: listener survives unmount
useEffect(() => {
  const onResize = () => { /* ... */ };
  window.addEventListener('resize', onResize);
  // missing: return () => window.removeEventListener('resize', onResize);
}, []);

Detached DOM nodes

Nodes removed from the document but still referenced from JS:

const cache = [];
function mount() {
  const el = document.createElement('div');
  document.body.append(el);
  cache.push(el); // retains after remove
  el.remove();
}

Frameworks: storing DOM nodes or fiber-era refs in global stores is a red flag.

Allocation timeline

Record Allocation instrumentation on timeline while reproducing. Spikes that never drop after GC suggest retained growth; sawtooth that returns to baseline is normal churn.

Retainers view

When you find a suspicious object, Retainers shows why GC can’t collect it — which array, which closure, which module-level Map. That’s the actual fix target.

SPA checklist

  1. Remove listeners / observers / ResizeObserver on unmount.
  2. Clear timers and intervals.
  3. Abort fetch or ignore stale results.
  4. Close WebSockets.
  5. Bound caches (Map with max size, not infinite).
  6. Revoke URL.createObjectURL after use.
const url = URL.createObjectURL(blob);
img.src = url;
// later
URL.revokeObjectURL(url);

Not every growth is a leak

BFCache, browser caches, and memoization grow deliberately. Confirm with three snapshots and user-flow reproduce. Force GC (DevTools trash icon) between comparisons carefully — understanding it still doesn’t run in users’ machines the same way.

Interview out-loud

“I take heap snapshots before and after a flow, look for detached DOM and growing retainers, then fix listeners and unbounded caches. Allocation timelines show churn vs true retention. Always clean up effects on unmount in SPAs.”

Framework-specific leak hunts

  • Global event buses without unsubscribe
  • React Query / SWR caches grown without bounds
  • Chart instances not dispose()’d
  • Map/WebGL contexts left alive on route change

Take a snapshot, interact 10 times, snapshot again — retained size should not climb linearly every cycle.

Further depth

Teams often under-invest in this topic until an incident or CWV regression. Schedule a one-hour drill: reproduce the failure mode in DevTools, list the top three mitigations for your stack, and file tickets with owners. Revisit after the next major feature that touches networking, rendering, auth, or third parties — those are the moments regressions land. Keep primary documentation links in the runbook so on-call is not searching chat history at 2am.

Concrete artifacts to leave behind: a short architecture note, a CI assertion or header snapshot, and a dashboard panel (lab or field) that would have caught the last bug. Teaching the rest of the team the mental model matters as much as the one-line fix.

Further reading

Related guides