A P R I M E R · O N M E M O RY
Garbage
collection
in JavaScript
You never call free. So when does memory actually go
away — and why does it sometimes refuse to?
FROM SHRUTI A TEACHING REFERENCE
CONTENTS
Thirteen scenes on letting
go.
How JavaScript decides what's still needed, and the everyday
patterns that quietly keep dead things alive.
Memory is automatic. — 03
01
Primitives sit in place. — 04
02
Reachability replaces reference counting. — 04
03
Roots are where the trace begins. — 05
04
Mark-and-sweep walks the graph. — 05
05
Cycles are not a problem. — 06
06
Most objects die young. — 06
07
Collection runs on the engine's schedule. — 07
08
Closures keep variables alive. — 07
09
Globals never die. — 08
10
Listeners and timers are silent retainers. — 08
11
Detached DOM nodes are the classic leak. — 09
12
WeakMap and WeakRef opt out. — 09
13
Pop quiz — 6 hard questions — 11
Quiz answers and explanations — 15
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 2
THE PRIMER
How JavaScript decides
what to keep.
Garbage collection sounds like janitorial work — sweeping up unused
values. But the interesting half is the opposite question: what counts
as used? Almost every memory bug in JavaScript is a wrong answer
to that question.
01 NO MALLOC, NO FREE
Memory is automatic.
In C, you ask for memory and you give it back. In JavaScript, you only ask. Every object literal,
array, string, and new call allocates — silently — and you never deallocate. The runtime
decides when each value is no longer needed, and reclaims it on its own schedule.
const user = { name: 'Ada' }; // allocation
const nums = [1, 2, 3]; // allocation
const greeting = `hi, ${[Link]}`; // allocation
// no free(). no delete. no destructor.
// the garbage collector handles it.
M E N TA L M O D E L
You request memory by creating values. The runtime owns the question of when to release
it.
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 3
02 VA LU E S V S . R E F E R E N C E S
Primitives sit in place.
Strings, numbers, booleans, null , undefined , symbols, and bigints are primitives.
Variables hold their value directly. Objects, arrays, and functions live on the heap, and
variables hold a reference to them. Garbage collection only worries about the second kind —
primitives are reclaimed automatically with the variable that held them, but two variables
pointing at the same object share one heap allocation that lives until nothing reaches it.
let a = 5;
let b = a; // b is a fresh copy of the value 5
b = 99; // a is still 5. no shared anything.
let x = { n: 5 };
let y = x; // y holds the same reference x does
y.n = 99; // x.n is now 99 too. one object, two pointers.
M E N TA L M O D E L
Primitives live where the variable lives. Objects live on the heap, and the variable holds a
pointer. GC is only about the heap.
03 THE ACTUAL RULE
Reachability replaces reference counting.
A naive collector might count references: when an object has zero references, free it.
JavaScript doesn't do that. The rule is stronger: an object is kept alive only if the engine can
still reach it by following references from a known starting point. Having references isn't
enough — those references must trace back to a root.
let a = { tag: 'A' };
let b = { tag: 'B', link: a };
// b references a. So {tag:'A'} has 2 references:
// the variable a, and [Link].
a = null; // {tag:'A'} still reachable via [Link].
b = null; // only now is {tag:'A'} unreachable.
TA K E A W AY
Don't think "how many references." Think "can the engine still get there from somewhere
it knows?"
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 4
04 W H E R E R E A C H A B I L I T Y S TA RT S
Roots are where the trace begins.
Reachability has to start somewhere. Those starting points are called roots. In JavaScript the
main roots are: the global object ( globalThis , window in browsers), every local variable in
every active function on the call stack, and anything held by built-in machinery like the timer
queue or event listeners. If a value is reachable by following references from any root, it stays.
const registry = {}; // global root: lives forever
function work() {
const tmp = { id: 7 }; // stack root, while work() runs
[Link] = tmp; // now reachable via root chain
}
work();
// tmp the variable is gone, but its object
// is still reachable: registry → latest → {id:7}
TA K E A W AY
Roots are globals, the live call stack, and engine-held references. Everything else has to
earn its keep by being reachable from one of those.
05 THE ALGORITHM
Mark-and-sweep walks the graph.
When the engine decides to collect, it does it in two phases. First it walks the reference graph
starting from every root, marking every object it can reach. Then it sweeps the heap: anything
not marked is dead and its memory is reclaimed. The unreachable set is found by exclusion —
you never enumerate it directly.
// Conceptually:
function collect() {
for (const obj of heap) [Link] = false;
for (const root of roots) mark(root);
for (const obj of heap) {
if (![Link]) free(obj);
}
}
MNEMONIC
Mark what's reachable. Sweep what isn't. The collector never has to ask "is this dead?" —
it asks "did anyone touch it?"
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 5
06 T H E B I G PAYO F F
Cycles are not a problem.
Two objects pointing at each other look terrifying to a reference counter — neither one ever
reaches zero. Mark-and-sweep doesn't care. If no root can reach the cycle, the trace simply
never visits those objects, and the sweep takes them both. This is the whole reason JavaScript
doesn't use reference counting.
function makeCycle() {
const a = {};
const b = {};
[Link] = b;
[Link] = a;
// a and b reference each other.
}
makeCycle();
// Function returned. No root reaches a or b.
// Both collected together. The cycle is irrelevant.
A reference counter would leak this forever. A reachability
collector doesn't even notice the cycle is there.
07 T H E G E N E RAT I O N A L B E T
Most objects die young.
Empirically, almost every object a program creates is short-lived — a temporary in a loop, a
return value, an intermediate in a chain. Engines exploit this by splitting the heap into a small
"young" region and a larger "old" region, and collecting the young region much more often.
New allocations go to young; survivors are eventually promoted to old. Old objects are
scanned rarely, on the assumption they're probably still in use.
function parse(input) {
const tokens = [Link](' '); // temp array
const trimmed = [Link](t => [Link]()); // temp array
return [Link]('-'); // only the string survives
}
// tokens and trimmed are stepping stones — created,
// used once, discarded. The young collector reaps them cheaply.
TA K E A W AY
The generational hypothesis: if an object survived its first few collections, it's probably
going to be around for a while. Scan it less.
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 6
08 ELIGIBLE, NOT ERASED
Collection runs on the engine's schedule.
Becoming unreachable doesn't mean disappearing instantly. Once a variable goes out of
scope or you assign null , the object becomes eligible — but the reclaim happens later,
when the engine decides to run a pass. You don't control the timing and can't force it, so
never write logic that depends on something having been freed.
function work() {
const blob = new Array(1e6).fill('x');
return blob[0];
}
work();
// blob is eligible the moment work() returns.
// Memory may be reclaimed in 5ms, or 500ms, or only
// when heap pressure justifies a pass. No callback.
TA K E A W AY
"Unreachable" and "freed" aren't the same thing. The first is a property of your code; the
second is a decision the engine makes later.
09 T H E F I R S T L E A K PAT T E R N
Closures keep variables alive.
When a function captures a variable from an enclosing scope, the runtime must keep that
variable's value alive for as long as the function itself is reachable. The closure is a reference.
If you keep the inner function — by returning it, assigning it, registering it as a callback — you
keep its captured scope too.
function makeCounter() {
let count = 0;
return () => ++count;
}
const next = makeCounter();
// makeCounter returned, but 'count' is alive —
// 'next' references the inner fn, which captures it.
TA K E A W AY
A closure is a hidden reference. To free what it captured, drop the function itself.
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 7
10 PERMANENT ROOTS
Globals never die.
The global object is a root. Anything attached to it is reachable for the entire life of the
program. That makes globals the easiest leak in the language: assign a large object to
[Link] or [Link] for "convenience," and you've opted that data out of
garbage collection until the page closes.
function processFile(file) {
const buffer = [Link](); // 50 MB
[Link] = buffer; // "for debugging"
return summarize(buffer);
}
// Every call replaces [Link].
// Previous buffers are freed — but only because
// the global slot was overwritten. Forget that
// assignment and the buffer lives forever.
TA K E A W AY
Treat the global object like a permanent log. Anything you write there outlives every
function call.
11 THE ASYNC LEAK
Listeners and timers are silent retainers.
A registered event listener and a scheduled timer are both held by the engine — they're roots.
The callback you handed them is reachable until you unregister it, and so is everything that
callback closes over. The local variable you used to hold the timer ID can vanish; the timer
queue still has its own reference.
function startPolling() {
const bigState = { rows: new Array(1e6) };
setInterval(() => {
[Link]([Link]);
}, 5000);
// We never stored the interval id. Can't clearInterval.
// The callback closes over bigState. The interval queue
// holds the callback. bigState lives forever.
}
TA K E A W AY
Every setInterval , setTimeout , and addEventListener needs a paired cleanup, or it
becomes an immortal root.
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 8
12 THE CLASSIC FRONTEND LEAK
Detached DOM nodes are the classic leak.
Remove a node from the document and you'd expect it to be freed. It often can't — because
some JavaScript variable still holds a reference. The node is detached from the page but
reachable from a root, so it stays, and so does its subtree, listeners, and everything those
listeners closed over. Every senior frontend engineer has hunted this leak.
const cache = [];
function render() {
const node = [Link]('div');
[Link](node); // strong reference kept
[Link](node);
}
render();
[Link] = ''; // node removed from DOM
// But cache[0] still holds it. Detached, but alive.
TA K E A W AY
"Removed from the DOM" and "garbage collected" are different events. If JavaScript still
holds the node, it stays — taking its whole subtree with it.
13 T H E E S C A P E H ATC H
WeakMap and WeakRef opt out.
Sometimes you need a reference that doesn't count — a cache keyed by an object, metadata
attached to a DOM node. WeakMap keys and WeakRef targets are invisible to the reachability
trace: if nothing else holds the object, it's collectible, and the weak structure quietly forgets it.
const metadata = new WeakMap();
function tag(node, info) {
[Link](node, info); // node is a weak key
}
// When the DOM node is removed and nothing else
// references it, the WeakMap entry disappears with it.
// A regular Map would have kept the node alive forever.
WHEN TO USE
Reach for WeakMap when you want to associate data with an object without extending the
object's life.
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 9
I N S U M M A RY
Four rules at the heart of
it.
Every garbage-collection question in JavaScript reduces to one of
these four ideas. Memorize them and the leak patterns become
obvious before you write them.
01 02
reachable from a root mark, then sweep
An object survives if and only if the The collector marks every reachable
engine can trace a path of references object starting from roots, then frees
from a root to it. Reference count is everything left unmarked. The dead set is
irrelevant. found by elimination.
03 04
young objects die young leaks = accidental retention
The heap is split by age. New allocations Every leak in JavaScript is a reference you
are scanned often and cheaply; survivors forgot you held — through a closure, a
graduate to a region scanned rarely. global, a listener, or a timer.
The garbage collector isn't asking "is this dead?" It's asking "did anyone remember
to forget about this?"
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 10
POP QUIZ
Six questions. No peeking.
Each question combines two or more of the rules above. The wrong
answers are the ones you'd pick if you only applied one rule. Trace
each example carefully — the call stack and the global object are
both roots, and that matters.
01 T H E R E F E R E N C E CYC L E
After buildPair() returns, can the two objects it created ever be garbage-
collected?
function buildPair() {
const a = { id: 'A' };
const b = { id: 'B' };
[Link] = b;
[Link] = a;
return undefined;
}
buildPair();
A Yes — both are unreachable, the cycle is irrelevant.
B No — they reference each other, so their reference counts never reach zero.
C Only a is collectible; b is held by [Link].
D Yes, but only after a full mark-and-compact, not a young-generation pass.
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 11
02 THE CLOSURE QUESTION
After the snippet runs, which of the large arrays is guaranteed to be reachable?
function make() {
const kept = new Array(1e6).fill('k');
const dropped = new Array(1e6).fill('d');
return () => [Link];
}
let fn = make();
fn = null;
A Both kept and dropped — closures retain the whole scope.
B Only kept — the returned function references it.
C Neither — the closure was dropped when fn = null.
D Only dropped — it's the older allocation.
03 THE FORGOTTEN TIMER
After start() returns, is cache eligible for collection?
function start() {
const cache = { rows: new Array(1e5) };
setInterval(() => {
[Link][0] = [Link]();
}, 10000);
}
start();
A Yes — start() returned, so its locals are unreachable.
B Yes — but only after the interval fires once.
C No — the timer queue holds the callback, which closes over cache.
D No — but only because cache is a const, not a let.
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 12
04 T H E G LO B A L S TA S H
After this code runs and handle() exits, can the buffer be collected?
function handle(req) {
const buffer = [Link]();
[Link] = buffer;
return [Link];
}
handle(someRequest);
A Yes — buffer was a local const; it dies with the call.
B Yes, if no other call frame is active.
C No — [Link] keeps the buffer reachable.
D No — const declarations are never collected.
05 THE WEAK MAP
After user = null , what happens to the WeakMap entry?
const sessions = new WeakMap();
let user = { id: 42 };
[Link](user, { token: 'abc' });
user = null;
A The entry stays forever — WeakMap is a normal Map with different syntax.
B The entry is removed immediately on user = null.
C The entry becomes collectible because nothing else strongly references the key.
D The value { token: 'abc' } is collected but the key persists.
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 13
06 THE MIXED REFERENCE
After this snippet, can the object be garbage-collected?
const strong = [];
const weak = [];
function add() {
const obj = { payload: new Array(1e5) };
[Link](obj);
[Link](new WeakRef(obj));
}
add();
A Yes — the local obj went out of scope.
B Yes — WeakRef makes its target eligible for collection.
C No — the strong array holds it; the WeakRef alone wouldn't.
D No — WeakRef counts as a reference like any other.
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 14
POP QUIZ · ANSWERS
The explanations.
For each one, notice which rule the correct answer applies — and
which rule the most plausible wrong answer would have required
you to forget.
01 A · Yes, both are unreachable
The cycle is the point. Reference counting would deadlock here — a points to b, b points to a,
neither count reaches zero. JavaScript doesn't use reference counting. The mark phase starts
from roots, never visits a or b, and the sweep takes them both. B is the textbook
misconception; C confuses "reachable from another live object" with "reachable from a root."
02 C · Neither is guaranteed reachable
After fn = null , nothing references the closure anymore — so the closure itself is
unreachable, and everything it captured goes with it. Answer A is the common misconception:
yes, closures retain their captured variables, but only as long as the closure itself is held.
Once you drop the function, you drop its scope. B would have been correct if fn were still
live; the question turns on that final assignment.
03 C · No, the timer queue holds it
The interval was never cleared, so the timer queue still holds the callback. The callback closes
over cache , which keeps cache reachable through the timer-queue root — even though
start() returned and its local cache binding is long gone. A is the trap: you'd apply rule
one (reachability from the stack) and forget that timer queues are also roots. This is one of
the most common real-world leak patterns.
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 15
04 C · No, globalThis keeps it alive
The local buffer binding does die with the call, but before it died it wrote a reference into
globalThis . The global object is a root; anything reachable from it stays. Until something
overwrites [Link] or deletes the property, the buffer is immortal. A applies
only the call-stack rule and ignores the global root.
05 C · The entry becomes collectible
WeakMap keys don't participate in reachability. With user = null , no strong reference to the
object remains; the WeakMap's hold on it doesn't count. The engine is free to collect both the
key object and its associated value, and the entry disappears from the map. B is wrong about
timing — collection happens on the GC's schedule, not synchronously on assignment — but
it's the closest of the wrong answers in spirit. A is the misconception WeakMap exists to
prevent.
06 C · No, the strong array holds it
A single strong reference is enough to keep an object alive, no matter how many weak
references also point at it. The strong array is reachable from a root (it's a const at module
scope), and it holds obj , so obj survives. The WeakRef would only become relevant if every
strong reference were dropped — at that point weak[0].deref() would start returning
undefined . B forgets that strong and weak references coexist; the weak one doesn't
downgrade the strong one.
G A R B A G E C O L L E C T I O N I N J AVA S C R I P T 16