Finding and Fixing JavaScript Memory Leaks in SPAs
A repeatable workflow for diagnosing memory leaks in single-page applications with heap snapshots, plus the five leak sources responsible for most real cases.
Quick answer
Diagnose SPA memory leaks by taking three heap snapshots around a repeated navigation and comparing retained objects. In practice almost every leak comes from one of five sources: event listeners never removed, timers left running, detached DOM held by a closure, subscriptions without teardown, and unbounded caches or arrays.
The symptom
The app is fine on load and sluggish after twenty minutes. Scrolling stutters, typing lags, and on mobile the tab eventually reloads itself. That is a leak, and guessing at the cause wastes days.
The three-snapshot method
- Open Memory in DevTools, take snapshot 1 on a settled page.
- Perform the suspect interaction ten times — usually navigating in and out of a route.
- Force garbage collection, take snapshot 2, repeat the interaction, take snapshot 3.
- Compare 3 against 1 filtered to 'Objects allocated between snapshots'. Anything growing linearly with the repetition count is your leak.
The five usual suspects
useEffect(() => {
const onScroll = () => setY(window.scrollY);
window.addEventListener("scroll", onScroll, { passive: true });
const id = setInterval(poll, 5000);
const sub = socket.subscribe(handle);
return () => {
window.removeEventListener("scroll", onScroll);
clearInterval(id);
sub.unsubscribe();
};
}, []);| Source | Tell-tale sign in the snapshot |
|---|---|
| Listeners not removed | Growing count of the handler closure |
| Timers still running | Detached components still updating state |
| Detached DOM | 'Detached HTMLDivElement' with a retaining closure |
| Unbounded cache | One Map or array growing forever |
| Global registry | Objects retained by window or a module singleton |
Prevention that costs nothing
- Use AbortController for fetches and listeners so one signal tears everything down.
- Bound every cache with a max size or TTL.
- Keep observers (Intersection, Resize, Mutation) disconnected on unmount.
- Avoid storing DOM nodes in module-level variables.
Checklist
- Repeatable interaction identified before profiling
- Three heap snapshots compared with forced GC
- Every listener, timer and subscription has a teardown
- AbortController used for fetches and listeners
- Caches bounded by size or TTL
- Observers disconnected on unmount
- Verified with a clean console in a fresh tab
Common mistakes
- Profiling with React StrictMode double-invocation and mistaking it for a leak.
- Only checking total heap size instead of retained object counts.
- Leaving console.log references to large objects during profiling.
- Adding listeners inside a render body rather than an effect.
- Treating a growing cache as a feature until the tab crashes.
Frequently asked questions
Do memory leaks affect SEO or Core Web Vitals?
They degrade INP badly on long sessions and low-memory devices, which is a field metric Google collects. A leak is a performance problem with a delay.
Does React clean up automatically?
React cleans up its own internals, not your side effects. Anything you subscribe to must be unsubscribed in the effect's return function.
How much heap growth is normal?
Some growth then a plateau is healthy. Linear growth proportional to the number of repeated interactions is a leak.
Summary
Reproduce the interaction, compare heap snapshots, look for objects growing with repetition, and fix the teardown. Five sources cover almost every real case.
working on something like this?