Topic 14.8
Memory and Garbage Collection
In one line
JavaScript frees memory automatically: the garbage collector keeps every object that can be reached from the roots (globals, the running functions, live closures and the DOM) and reclaims the rest. A memory leak is an object you no longer need that is still reachable, most often through a forgotten listener, timer, closure, cache or detached DOM node.
Think of it like this
Balloons on strings. Every object is a balloon. You hold some strings in your hand (your variables), and some balloons are tied to other balloons. Any balloon you can reach by following strings from your hand stays. A balloon whose last string is cut floats away, and the cleaner (the garbage collector) pops it later. A leak is a balloon you no longer care about but that is still tied, by a string you forgot, to something you're holding.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Heap
- The memory area where objects, arrays, functions and closures are stored.
- Stack
- Memory for running functions' local values, freed automatically when each function returns.
- Garbage collector (GC)
- The part of the engine that finds objects you can no longer reach and frees their memory.
- Root
- A starting point the GC always keeps: globals, running functions' variables, the DOM, timers and listeners.
- Reachable
- Can be reached from a root by following references. Reachable objects are kept.
- Mark-and-sweep
- The GC method: mark everything reachable, then free everything not marked.
- Memory leak
- Memory that stays in use for objects you no longer need, because something still references them.
- Detached DOM
- Elements removed from the page but still referenced by JavaScript, so they can't be freed.
- Heap snapshot
- A DevTools record of every object in memory at one moment, used to find what is leaking.
Step by step
01Follow the references from the roots
Picture memory as a graph: objects are boxes, references are arrows. The GC starts at the roots and follows every arrow. Boxes it never reaches are garbage, including the D and E cycle below.
02Drop a reference, and an object becomes garbage
Setting a variable to null or letting it go out of scope removes one arrow. If that was the last path from a root, the object is now unreachable; it will be freed on some later GC run, not immediately.
let user = { name: "Asha", photos: new Array(1e6) };
user = null; // the object is unreachable now; the GC frees it later03Spot a listener leak
The widget is removed, but window still holds the resize handler, the handler's closure holds el, and el holds its whole subtree. Nothing can be freed, and the handler keeps doing work.
function mountWidget(container) {
const el = document.createElement("div");
container.append(el);
window.addEventListener("resize", () => {
el.textContent = innerWidth + "px";
});
return () => el.remove(); // removes from page, but the listener remains
}04Clean up with AbortController
Give every listener the same signal. One abort() removes them all, so unmount code can't forget one.
function mountWidget(container) {
const controller = new AbortController();
const el = document.createElement("div");
container.append(el);
window.addEventListener("resize", () => {
el.textContent = innerWidth + "px";
}, { signal: controller.signal });
const timer = setInterval(() => el.classList.toggle("blink"), 1000);
return () => {
controller.abort();
clearInterval(timer);
el.remove();
};
}05Watch closures that capture too much
A closure keeps the variables it uses alive. Here the returned function only needs big.length, but it references big, so the whole array stays as long as the function does. Copy out what you need.
function makeReport(big) {
// leaks: return () => "rows: " + big.length;
const rows = big.length; // keep only the number
return () => "rows: " + rows;
}06Find leaks in DevTools
Open Memory, take a heap snapshot, do the suspect action several times (open and close a dialog), click the bin icon to force GC, take another snapshot, and choose Comparison. Growing counts of your objects, or Detached <div> entries, point at the leak; the Retainers pane shows the chain of references keeping each one alive.
Try it yourself
- 1
Make a cycle reachable
In the toy heap, change
C: []toC: ["D"]. Predict the reachable and garbage lists before running. - 2
Forget the abort
In the listener example, remove
controller.abort();from the fixed version. Predict its call count now. - 3
Find a real detached node
Open DevTools on any page, run
window.kept = document.body.firstElementChild; kept.remove();, take a heap snapshot in the Memory panel and typeDetachedin the class filter. Then runkept = null, force GC with the bin icon and snapshot again.
Code & diagrams
D and E point at each other, yet they're garbage: nothing reachable points to them. Reference counting would have kept them forever.
Preview
Console
Nothing logged yet.Expected console output
reachable: [ 'A', 'B', 'C' ]
garbage: [ 'D', 'E', 'F' ]
after A drops B: [ 'A' ] garbage: [ 'B', 'C', 'D', 'E', 'F' ]The leaky element is detached (not on the page) but `window` still holds the handler, which still holds and updates the element. That's a detached DOM leak.
Preview
Console
Nothing logged yet.Expected console output
leaky: handler ran 2 times after unmount
fixed: handler ran 0 times after unmount
leaky element on the page? false
but still kept alive by the listener: trueA running interval is a root: its callback and everything that callback uses stay alive until `clearInterval`.
Preview
Console
Nothing logged yet.Expected console output
leaky timer still ticking: true
fixed timer ticks after stop: 0A Map keeps insertion order (Topic 14.3), so its first key is always the least recently used. `b` was evicted when `d` arrived, and memory stays bounded.
Preview
Console
Nothing logged yet.Expected console output
[ 'c', 'a', 'd' ]
undefined 3Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Keep removed rows in an array
Store every table row you create in const allRows = [] and later remove rows from the page with row.remove() only.
Break #2
Add a listener on every render
Call document.addEventListener("keydown", onKey) with a new arrow function inside a render() that runs on every state change.
Myth vs fact
Myth
Setting a variable to null frees memory immediately.
Fact
It only removes one reference. The object is freed later, if nothing else reaches it, whenever the GC runs.
Myth
Circular references cause memory leaks in JavaScript.
Fact
Not with mark-and-sweep. Cycles are collected as soon as no root reaches them. (Very old Internet Explorer leaked on JS-DOM cycles because its DOM used reference counting.)
Myth
delete obj.prop frees memory.
Fact
It removes a property (one reference). Whether the value is freed depends on other references. delete on objects can also slow property access by changing the object's hidden shape.
Myth
Garbage-collected languages can't leak.
Fact
They can't leak unreachable memory, but anything you keep reachable by mistake stays forever. Most web app leaks are exactly that.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Closures in V8 share one context object per scope: if two inner functions are created in the same function, a variable captured by either is kept alive by both. A long-lived small callback can thus retain a big array used only by a sibling closure. Splitting functions or nulling the variable fixes it.
- ▸
performance.memory(Chrome only, deprecated) andperformance.measureUserAgentSpecificMemory()(needs cross-origin isolation) give memory numbers in code. For investigation, DevTools' heap snapshot Retainers view and the Allocation instrumentation on timeline are far more useful than numbers. - ▸
In the young generation, allocation is a pointer bump and a scavenge only touches survivors, so creating many short-lived objects is cheap. What hurts is keeping lots of objects alive for a medium time: they get promoted and cost a full mark-compact later.
- ▸
Single-page apps leak most at route changes: components unmount but global stores, listeners, observers (
IntersectionObserver,ResizeObserverneeddisconnect()), WebSocket handlers and third-party widgets keep references. Frameworks provide cleanup hooks (useEffectreturn,onUnmounted); the bug is forgetting to use them.
Remember this
- 1
Memory is where a program keeps its data. Small fixed things (numbers in local variables, the record of which function called which) live on the stack, which is cleaned automatically when a function returns. Objects, arrays, functions, strings and closures live on the heap, a big shared area. You never free heap memory yourself in JavaScript: the engine's garbage collector (GC) does it.
- 2
The rule the GC uses is reachability. It starts from the roots: the global object and global variables, the local variables of functions currently running (the call stack), and things the host holds, such as the DOM tree, pending timers, event listeners and pending promises' callbacks. Any object that can be reached from a root by following references is live and kept. Everything else is garbage, even if objects inside it point at each other.
- 3
Modern engines use mark-and-sweep: mark every object reachable from the roots, then sweep (free) everything unmarked, often followed by compaction (moving live objects together to avoid gaps). An older idea, reference counting (free an object when nothing points to it), fails on cycles: two objects that point at each other would never be freed. Mark-and-sweep handles cycles correctly, which is why
a.friend = b; b.friend = ais not a leak once you drop both. - 4
V8 (Chrome, Node.js) uses a generational collector (named Orinoco). Most objects die young, so new objects go into a small young generation that is collected very often and fast (the Scavenger copies survivors out). Objects that survive a couple of rounds move to the old generation, collected less often by a mark-compact collector that works mostly concurrently (on helper threads while your code runs) and incrementally (in small slices) to keep pauses short. You can't control when it runs, and you shouldn't try.
- 5
The four classic leaks in browser code: (1) Event listeners you add to
window,documentor a long-lived element and never remove: the handler's closure keeps everything it uses alive. (2) Timers: asetIntervalyou never clear runs forever and keeps its closure alive. (3) Closures that capture more than they need, stored somewhere long-lived (a cache, a global array of callbacks). (4) Detached DOM: elements removed from the page but still referenced from JavaScript (a variable, an array, a Map key), so the whole removed subtree stays in memory. A fifth one is everywhere: unbounded caches, Maps that only ever grow. - 6
Fixes follow from the rule: cut the string. Remove listeners (
removeEventListener, or pass{ signal }from anAbortControllerand callabort()once to remove them all, see Phase 9 on events), clear timers (clearInterval), set long-lived references tonullwhen done, key per-object data by a WeakMap (Topic 14.4), and give caches a size limit (LRU: evict the least recently used). To find leaks, use DevTools: the Memory panel's heap snapshots (take one, do the action, take another, compare; filter by "Detached" to find detached DOM), and the Performance panel's memory graph, where a sawtooth that keeps climbing is a leak.
Explain it without notes
How does the JavaScript garbage collector decide what to free?
Why does mark-and-sweep handle circular references when reference counting doesn't?
Name four common sources of memory leaks in browser code and how to fix each.
How would you find a memory leak in a web app?
Practice
Rewrite function track() { setInterval(() => console.log("tick"), 1000); } so the caller can stop it, and show the stop call.
This code keeps every removed card in memory: const cards = []; function add() { const c = document.createElement("div"); cards.push(c); document.body.append(c); } function removeAll() { cards.forEach((c) => c.remove()); }. Fix removeAll so the cards can be collected.
Trade-offs
- ↔
Caching results saves CPU but holds memory; bounded caches (LRU) or WeakMaps trade some repeated work for predictable memory.
- ↔
Automatic GC removes whole classes of bugs (double frees, dangling pointers) at the cost of control: you can't choose when it runs, and pauses, though short today, can still show up in animation-heavy code that allocates a lot.
Done when you can
I can explain roots, reachability and mark-and-sweep.
I know why cycles aren't leaks but forgotten references are.
I can recognise and fix listener, timer, closure, detached DOM and cache leaks.
I can clean up listeners with an
AbortController.I can use heap snapshots and the Detached filter to find a leak.