JavaScript Memory and Garbage Collection: Why Your SPA Leaks
JavaScript developers rarely think about memory until a single-page app starts at 50MB and reaches 500MB after an hour of use. By then the leak is hard to find. The fix is not to memorize hacks — it is to understand the garbage collector well enough to write code that does not feed it bad inputs.
This is Part 9 of the JavaScript Fundamentals series. We cover how V8's GC actually works, the four classic leak patterns in modern web apps, and how to debug a real leak with Chrome DevTools.
The two assumptions developers make #
Most JavaScript developers assume:
- "The GC handles everything; I do not need to think about memory."
- "If I do think about it, my code does not have memory leaks."
Both are wrong. The GC handles a lot, but JavaScript's reference model makes it easy to keep objects alive without realizing. SPAs that mount and unmount components, register event listeners, capture closures, and cache data are the prime suspects.
How V8's garbage collector works (the short version) #
V8 (the engine in Chrome and Node) uses a generational, mark-and-sweep collector. Two simplifying ideas:
Idea 1: Most objects die young. Newly allocated objects are far more likely to be discarded than long-lived ones. So V8 maintains two heaps:
- Young generation (small, fast, sweeps often)
- Old generation (larger, sweeps less often)
Objects start in the young generation. If they survive a few collection cycles, they get promoted to the old generation.
Idea 2: Reachability is the entire criterion. An object survives if and only if it is reachable from a set of roots:
- The current call stack (local variables, function arguments)
- The global object (
window/globalThis) - The active microtask and macrotask queues
- Any closure that captures it
- Any other reachable object that references it
The collector starts at the roots, follows every reference, marks every reachable object. Then it sweeps the heap and reclaims everything unmarked.
That is the entire GC. The complications (incremental marking, concurrent sweeping, compaction) are performance optimizations on top of this simple model.
A leak is an object that should be dead but is still reachable #
This is the entire mental model for finding leaks. An object survives if any chain of references from a root still reaches it. If you can answer "what root keeps this alive?", you have found the leak.
Four classic patterns where the answer is not obvious:
Leak Pattern 1: Forgotten event listeners #
function setupTooltip(el) {
function handler(e) { showTooltip(el, e); }
document.addEventListener('mousemove', handler);
}
When you call setupTooltip(div) and then later remove div from the DOM, the mousemove listener still references handler, which captures el (and indirectly the whole subtree). The detached DOM tree is unreachable from the document but reachable from the listener — alive forever.
Fix: always remove what you add.
function setupTooltip(el) {
function handler(e) { showTooltip(el, e); }
document.addEventListener('mousemove', handler);
return () => document.removeEventListener('mousemove', handler);
}
const cleanup = setupTooltip(div);
// ...later, when tearing down:
cleanup();
In React, useEffect returns from its setup function for this exact reason — the returned cleanup runs on unmount.
Leak Pattern 2: Closure captures more than you think #
function setup(bigData) {
document.querySelector('button').onclick = () => {
console.log(bigData.id);
};
}
The arrow captures bigData by reference. Even though you only need .id, the entire object stays alive as long as the click handler is attached. If bigData is a 10MB JSON blob, that is 10MB you cannot reclaim.
Fix: extract only what you need.
function setup(bigData) {
const id = bigData.id; // bigData now eligible for GC
document.querySelector('button').onclick = () => console.log(id);
}
We explored the closure side of this in Part 4.
Leak Pattern 3: Caches that grow forever #
const cache = new Map();
export function getUser(id) {
if (cache.has(id)) return cache.get(id);
const user = fetchUserSync(id);
cache.set(id, user);
return user;
}
The cache is module-level (reachable from the global module record forever). Every user fetched is held forever. In a long-lived SPA or Node server, this fills RAM.
Fix options:
- Bounded LRU: cap the size, evict least-recently-used:
// Pseudo-LRU via Map insertion order function lruSet(key, value, max = 1000) { cache.delete(key); // re-insert at end cache.set(key, value); if (cache.size > max) cache.delete(cache.keys().next().value); } - TTL: expire entries after N seconds.
- WeakMap: if the key is itself an object that will eventually become unreachable, use a
WeakMapso the cache entry dies with the key. See Part 10.
Leak Pattern 4: Detached DOM kept alive by JavaScript #
const rows = [];
function render(data) {
data.forEach(d => {
const tr = document.createElement('tr');
tr.textContent = d.name;
tableBody.appendChild(tr);
rows.push(tr); // ← keeps every row alive forever
});
}
function clear() {
tableBody.innerHTML = ''; // ← removes from DOM but rows[] still holds them
}
The innerHTML = '' detaches the rows from the DOM tree, but the rows array still references each <tr>. Each <tr> references its children. Detached DOM trees that JavaScript can still reach are reported separately in Chrome DevTools — and they are a very common leak source in custom table renderers and chart libraries.
Fix: clear the JS reference array, too.
function clear() {
tableBody.innerHTML = '';
rows.length = 0; // ← let the rows GC
}
Hidden roots: timers and intervals #
setInterval(() => doSomething(this.state), 1000);
The interval reference itself is held by the timer queue (a root). The callback captures this, which keeps the component alive. If the component is meant to unmount, you must clearInterval it.
In React: useEffect(() => { const id = setInterval(...); return () => clearInterval(id); }, []).
How to find a real leak with Chrome DevTools #
The standard workflow:
- Open DevTools → Memory tab.
- Take a heap snapshot of the page in its baseline state.
- Perform the action that should be GCed (open a modal, navigate, etc).
- Reverse the action (close, navigate back).
- Click "collect garbage" (the trash-can icon).
- Take another snapshot.
- Choose "Comparison" view — compare the new snapshot to the baseline.
- Look for the "Delta" column — types that gained instances are suspects.
- Expand a suspect → look at its Retainers — that is the chain of references keeping it alive.
The most useful instrument here is the "Detached HTMLDivElement" and "Detached HTMLLIElement" entries — they're exactly Leak Pattern 4.
Manual GC hints (limited) #
JavaScript has no delete for variables (it has delete obj.key for properties), and you cannot force GC. But you can:
- Null references to hint deallocation:
let bigData = await loadHugeBlob(); process(bigData); bigData = null; // hint to GC --expose-gcin Node: allows callingglobal.gc()manually. Useful in tests and profiling, never in production.- In Chrome DevTools: the trash-can button forces a full GC. Useful for snapshots, not production.
In 99% of cases, the right fix is removing the reference that keeps the object alive, not asking the GC to try harder.
Memory in Node.js #
Node adds two concerns:
- Long-running processes accumulate small leaks into big ones. Restart-on-deploy is common, but does not save you if a single request leaks 1KB and you handle 100k requests/day.
- Heap snapshot from inside the process:
process.memoryUsage()givesheapUsed/heapTotal. For deep snapshots,v8.writeHeapSnapshot()writes a file you load into Chrome DevTools. - The
--max-old-space-size=4096flag raises the V8 old-generation cap to 4GB. Useful for big batch jobs; a red flag for normal servers.
Common gotchas #
Gotcha 1: WeakRef does NOT free memory immediately. We will go deep on this in Part 10, but the short version: weak references give the GC permission to reclaim, not a command. The GC still chooses when.
Gotcha 2: Memory snapshots include the snapshot tool's own allocations. DevTools-generated objects show up in heaps. Look at deltas, not absolutes.
Gotcha 3: Strings are immutable but interned — they often don't leak the way arrays do.
A 10MB string allocated, then dereferenced, is GCed normally. But a 10MB substring (from .slice or .substring) might secretly retain the entire parent string. Modern V8 has mostly fixed this, but older runtimes are bitten.
Gotcha 4: Modules cache forever.
In ESM and CommonJS, import / require caches the module. Top-level constants in a module survive the entire program. Be deliberate about what you put at module level.
Recap #
- V8 uses generational mark-and-sweep: young + old heaps, mark from roots, sweep the rest.
- An object survives if any root can still reach it. A leak is an object that should be dead but is still reachable.
- Four classic leak patterns: forgotten event listeners, over-capturing closures, unbounded caches, detached DOM kept alive by JS arrays.
- Hidden roots: timers, intervals, microtask/macrotask queues, module scope.
- Debug with DevTools → Memory → Snapshot Comparison. The Retainers panel is the answer key.
- You cannot force GC; you fix leaks by removing the reference that keeps the object alive.
Next up: WeakMap, WeakSet, and WeakRef — the three data structures that let you reference objects without keeping them alive, and the precise use cases that justify them.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


