Garbage Collection and Memory Leaks
How a garbage collector decides what is alive by tracing from roots, why generations and pauses matter for latency, and how JavaScript apps still leak memory.
A tracing garbage collector (GC) does not look for objects you stopped using. It starts from the roots (the call stack and global variables), follows every reference, and frees whatever it could not reach. Most runtimes split the heap into generations because most objects die young, which makes the common collection cheap and the rare full one expensive. So a leak in a collected language is never a forgotten free(): it is a reference that keeps an object reachable, usually from a listener, a timer, a cache or a global.
Context
In C you allocate with malloc and release with free, and decades of bugs come from getting that wrong: memory freed twice, used after it was freed, or never freed at all. John McCarthy invented automatic garbage collection for Lisp around 1959, and George Collins described reference counting in 1960. Java (1995) made a collected runtime the mainstream default, and JavaScript, Python, C#, Go and Ruby followed. Swift chose compile-time reference counting instead, and Rust avoids a collector entirely by checking ownership at compile time.
You rely on it every time a function returns a large value and you never think about releasing it, and you meet its failures as a Node.js process dying with JavaScript heap out of memory, a browser tab that grows to 2 GB after an hour, or a Java service whose p99 latency jumps during a GC pause. The simplest case:
function count() {
const data = new Array(1_000_000).fill(0) // ~8 MB on the heap
return data.length
}
count() // after return, nothing references the array:
// it is unreachable, so the next GC may free it- Heap
- The memory region where objects live; the collector manages it. Primitives in local variables may live on the stack instead.
- Roots
- References the program can use directly without following another object: local variables on the stack, globals, CPU registers, and engine handles.
- Reachable
- Can be found by following references from a root. Reachable objects are kept, whether or not your code will ever use them again.
- Stop-the-world pause
- A moment when the collector halts the program so the object graph stops changing under it.
- Retainer
- An object that holds a reference to another and so keeps it alive. A leak is diagnosed by walking the retainer path back to a root.
Why it matters
Two things go wrong in production. Memory that is still reachable but no longer useful grows until the process is killed, which in a container shows up as restarts and in a browser as a slow, swapping tab. And collection itself costs time: a pause of 50 ms is invisible in a batch job, adds directly to the tail latency of an API, and drops three frames in a UI that has 16.7 ms per frame at 60 Hz. Knowing how the collector works tells you which allocation patterns are cheap, which references keep things alive, and how to prove a leak instead of guessing.
Reachability, not "no longer used"
A collector cannot know what your code will do next, so it uses the only test it can compute: can this object still be reached? Marking starts at the roots and follows every reference transitively; everything marked survives, everything else is garbage. That is why an object you will never touch again stays alive if a forgotten listener still points at it, and why two objects that point at each other but at nothing reachable are collected together.
Reference counting
The other family keeps a counter in every object, increments it when a reference is created and frees the object the moment it drops to zero. CPython does this, which is why a file object closes as soon as its last variable goes away, and Swift does it at compile time as ARC (automatic reference counting). Memory is reclaimed promptly and without pauses, but every assignment pays for counter updates and a cycle never reaches zero. CPython therefore runs an extra cycle collector (the gc module, since Python 2.0), and Swift asks you to break cycles yourself with weak or unowned references.
Generations, compaction and pauses
Tracing the whole heap on every collection would be slow, and it is unnecessary because of the generational hypothesis (Lieberman and Hewitt, 1983): most objects die young. Request bodies, temporary arrays and closures for a single callback are garbage within milliseconds. So engines allocate into a small young generation, collect it often and cheaply, and promote the few survivors to an old generation that is collected rarely.
- 1Young collection (scavenge). Live objects are copied from one half of the young space to the other; everything left behind is garbage and the half is reused wholesale. Cost is proportional to survivors, not to garbage, which is why short-lived objects are almost free.
- 2Mark. For the old generation, the collector traces from the roots and marks every reachable object. V8 does most of this concurrently on helper threads while JavaScript keeps running.
- 3Sweep. Unmarked memory is added to free lists so new objects can reuse it.
- 4Compact. Live objects are moved together so free memory is not scattered in small holes. Moving means updating every pointer to them, so this is the step most likely to need a pause.
V8's Orinoco project (2016 to 2019) made the scavenger parallel and old-generation marking concurrent and incremental, so most collection work happens off the main thread and pauses are usually a few milliseconds. Other runtimes make different trade-offs:
| Runtime | Strategy | Pauses | Main knob |
|---|---|---|---|
| JavaScript (V8) | Generational: scavenger + concurrent mark-compact | Short; most marking off-thread | --max-old-space-size |
| Java | G1 default since JDK 9; ZGC and Shenandoah compact concurrently | G1 aims at a target; ZGC sub-millisecond | -Xmx, choice of collector |
| Go | Concurrent tri-color mark-sweep, no generations, no moving | Sub-millisecond phases | GOGC |
| Python | Reference counting plus a cycle collector | Refcount frees at once; cycle GC periodic | gc |
| Swift | ARC: compiler-inserted reference counting | No collector pauses | weak, unowned |
| C# (.NET) | Generational gen0/1/2 plus a large object heap | Background GC for gen2 | Workstation vs server GC |
How JavaScript apps still leak
Every leak in a collected language is an unwanted path from a root. In practice there are five usual suspects: an event listener or timer registered on a long-lived object (window, a socket, an emitter) and never removed; a DOM node removed from the page but still referenced from JavaScript (a detached node, which keeps its whole subtree); a cache or Map that only grows; an accidental global; and a long-lived closure that captures more than it needs, covered in depth in the closures topic.
class Panel {
constructor(root) {
this.root = root
this.rows = new Array(50_000).fill({})
// window outlives the panel; the arrow captures `this`
window.addEventListener('resize', () => this.layout())
}
layout() { /* … */ }
destroy() {
this.root.remove() // listener stays → Panel, rows and the
} // detached root stay reachable forever
}class Panel {
#ac = new AbortController()
constructor(root) {
this.root = root
this.rows = new Array(50_000).fill({})
window.addEventListener('resize', () => this.layout(),
{signal: this.#ac.signal})
}
layout() { /* … */ }
destroy() {
this.#ac.abort() // removes every listener tied to the signal
this.root.remove()
}
}For data attached to objects you do not own, a WeakMap (ES2015) is the right container: its keys are held weakly, so an entry disappears when its key becomes unreachable elsewhere, and it cannot be iterated, so nothing can accidentally keep entries alive by listing them.
// metadata keyed by element: the entry dies with the element
const boxes = new WeakMap<Element, DOMRect>()
export function measure(el: Element): DOMRect {
let box = boxes.get(el)
if (!box) {
box = el.getBoundingClientRect()
boxes.set(el, box)
}
return box
}
// with a Map, every element ever measured would stay alive,
// including the ones already removed from the pageFinding a leak
Rising memory is not proof of a leak: the heap legitimately grows until the collector decides it is worth running. The signal is memory that keeps rising after the same action is repeated and a GC has been forced. The three-snapshot technique, popularised by the Gmail team, turns that into evidence:
- 1Warm up: perform the suspect action once (open and close the panel, run one request) so caches and lazy modules are loaded.
- 2Take heap snapshot 1 in the DevTools Memory panel (it forces a GC first). Repeat the action several times; take snapshot 2. Repeat again; take snapshot 3.
- 3View snapshot 3 in Comparison or filter to objects allocated between snapshots 1 and 2 that still exist in 3. Objects that grow by the same count per repetition are the leak.
- 4Select one and read its Retainers path up to a root: a listener, a timer, a cache entry or a detached DOM tree. That path tells you which reference to remove.
# open chrome://inspect and use the same Memory panel
node --inspect server.js
# or write snapshots from code (v8 module) and compare offline
node -e "require('v8').writeHeapSnapshot()"Pitfalls
- Unbounded caches and Maps
A module-level
Mapused as a cache keeps every key and value reachable for the life of the process, because the module is a root. In a server that serves many different keys this is a slow leak. Bound it (LRU with a maximum size or a time to live) or use aWeakMapwhen the key is an object whose lifetime should decide the entry's. - Listeners and timers without teardown
Anything registered on a longer-lived object (window, document, a socket, a global emitter,
setInterval) holds its callback, and the callback holds whatever it closes over. In components, every subscription in an effect needs a cleanup; anAbortControllerlets one call remove all of them. - Mistaking normal heap growth for a leak
Engines grow the heap until collection pays off, so a sawtooth that rises and drops is normal. Only growth that survives forced collections across repeated identical actions is a leak; measure with snapshots before rewriting code.
- Relying on finalizers for cleanup
FinalizationRegistrycallbacks, Java finalizers and C# destructors run at the collector's convenience, possibly never. Resources with real limits (file handles, connections, locks) must be released explicitly, or the process runs out of them long before it runs out of memory. - Raising the heap limit to fix a leak
--max-old-space-sizeor a bigger container only postpones the crash, and a larger heap makes each full collection longer. Raise it when the live data genuinely needs the room; for a leak, find the retainer.
Interview questions
Q1How does garbage collection work in JavaScript?
V8 uses a tracing, generational collector. It considers an object alive if it is reachable from roots such as the stack and globals; new objects go into a young generation that a copying scavenger empties frequently, and survivors are promoted to an old generation collected by mark-sweep-compact, mostly concurrently on background threads. Unreachable objects are freed regardless of cycles.
Q2Can you have memory leaks in a garbage-collected language?
Yes: the collector only frees unreachable objects, so anything still referenced stays, even if you will never use it. The usual causes are listeners and timers that are never removed, detached DOM nodes held from JavaScript, caches that only grow, accidental globals and long-lived closures capturing large data.
Q3What is the difference between reference counting and tracing?
Reference counting frees an object the moment its count hits zero, which gives prompt, pause-free reclamation but costs an update on every reference change and cannot free cycles. Tracing periodically walks from the roots and frees whatever it did not reach, which handles cycles and has no per-assignment cost but needs collection pauses or concurrent machinery. CPython combines both; Swift uses only counting and makes you break cycles with weak references.
Q4What happens when a component registers a resize listener and is unmounted without removing it?
The window keeps a reference to the listener, the listener closes over the component instance or its state, and so the whole instance, its data and often its detached DOM stay reachable. Each mount and unmount adds another copy, so memory grows with usage and the stale handlers keep running. The fix is a cleanup in the effect, or registering with an AbortSignal and aborting it on unmount.
Q5Walk me through finding a memory leak in a Node.js service.
First confirm it: memory that keeps rising across the same workload after GC, not just a growing heap. Then reproduce locally with --inspect, take a heap snapshot after warm-up, run the suspect workload, take two more, and compare the objects that grew by a consistent count. Follow their retainer path to a root, usually a module-level cache, an emitter listener or a closure, fix that reference, and verify the next run is flat.
Q6When would you use a WeakMap instead of a Map?
When the key is an object and the entry should live exactly as long as that object: metadata for DOM elements, per-request data keyed by the request, memoized results keyed by an input object. The WeakMap does not keep keys alive, so entries vanish with their keys; the price is that keys must be objects and you cannot iterate or size the map.
Q7Why do GC pauses matter, and how do runtimes reduce them?
A stop-the-world pause adds directly to the latency of whatever request or frame is in flight, so it shows up in p99 and in dropped frames. Runtimes shrink pauses by collecting the young generation separately, by marking concurrently and incrementally, and by compacting concurrently as Java's ZGC does; Go avoids compaction altogether. On the application side, allocating less in hot paths reduces how often collection runs.
- A tracing collector keeps what is reachable from the roots, not what you still intend to use; unreachable cycles are freed.
- Reference counting frees promptly but cannot handle cycles without help; CPython adds a cycle collector and Swift relies on weak references.
- Generations make short-lived objects almost free; long-lived ones are collected by slower mark-compact, mostly concurrently in modern runtimes.
- JavaScript leaks are references that outlive their purpose: listeners, timers, detached DOM, growing caches, accidental globals.
- Use WeakMap for per-object metadata, AbortController for teardown, and never finalizers for releasing real resources.
- Prove a leak with repeated heap snapshots and follow the retainer path to a root before changing code or limits.