Lesson 1 of 6
Memory, References & Garbage Collection
"Memory is the treasury and guardian of all things." — Cicero
You have now used JavaScript across the whole surface of the language: values and objects, functions and scope, the event loop and Promises, the DOM and the network. Every one of those chapters quietly assumed something it never explained — that when you write const user = {…}, the engine finds somewhere to put it, and later takes that somewhere back. This is where Part VI begins its descent. We stop asking what JavaScript does and start asking how the engine keeps the lights on: where your data actually lives, how variables point at it, and who cleans up when you're done. Get this right and half the "mysterious" bugs and slowdowns you'll ever meet turn into things you can see.
You never call free() in JavaScript — so where does an object go when you're finished with it, and what quietly keeps it alive longer than you meant?
Core Concepts
The stack and the heap
A running JavaScript program keeps its memory in two very different places, and the difference explains almost everything that follows.
The stack is small, fast, and rigidly ordered. It holds exactly what Part II's call stack described: one frame per function call, stacked and popped in perfect last-in-first-out order. Each frame stores that call's local variables — but only fixed-size things: primitives (number, boolean, null, undefined, small strings conceptually) and references. When a function returns, its frame is popped and its slots vanish instantly. No cleanup, no thought required.
The heap is the big, unordered warehouse. Every object, array, and function you create lives here, because their size isn't known in advance and they outlive the call that made them. The heap has no tidy pop-on-return discipline — which is exactly why it needs a garbage collector.
| Stack | Heap | |
|---|---|---|
| Holds | Primitives, references, call frames | Objects, arrays, functions |
| Size | Fixed, known ahead of time | Dynamic, grows as needed |
| Order | Strict LIFO (push/pop) | Unordered warehouse |
| Cleanup | Automatic on function return | Garbage collector, later |
| Speed | Very fast | Slower, indirection via reference |
The stack is the stack of trays at a canteen — you only ever add to or take from the top, and it's effortless. The heap is the cloakroom: you hand over a coat and get a numbered ticket. The ticket (small, fits in your pocket — that's the reference on the stack) isn't the coat; it's how you find the coat (the object in the heap). Lose every ticket for a coat and nobody can ever collect it — the cloakroom is free to give the peg away. That's garbage collection in one sentence.
References: why mutation is shared
Here is the memory reality behind Part I's Objects chapter. A primitive is copied by value: assign it and you get an independent twin. An object is held by reference: the variable on the stack stores only the address of the object in the heap. Assign that variable to another and you copy the address, not the object — now two tickets, one coat. Mutate through either handle and both "see" the change, because there was only ever one thing.
Read the last third slowly. Setting node = null did not destroy anything — it rebound one variable. The object survived because ref still held it. This is the mental model that dissolves the classic "why did editing this array change that one too?" bug: you never had two arrays. You had two tickets for one coat.
Reachability: the one idea that matters
JavaScript is garbage-collected. You allocate by creating values; you never deallocate. The engine reclaims memory on its own — but on one strict principle: an object is kept alive if and only if it is reachable. Reachability means there is a chain of references leading to the object, starting from a root. The roots are the things the engine always considers live:
- the global object (
globalThis,windowin a browser), - everything on the current call stack (local variables of running functions),
- and every variable captured by a live closure.
Follow references outward from those roots; anything you can reach is live. Everything you cannot reach — no matter how large, no matter how recently used — is garbage. Note what this is not: it is not reference-counting-by-hand, and it copes fine with cycles. Two objects that point at each other but that no root can reach are both unreachable, and both collected together. That is the trap old reference-counting schemes fell into, and reachability sidesteps it entirely.
The idea is older than JavaScript by four decades. John McCarthy invented garbage collection in 1959 for Lisp, so programmers could stop hand-tracking memory. For years "real" systems languages dismissed it as too slow — C and C++ made you manage memory yourself, and a generation of segfaults and use-after-free security holes followed. Then Java (1995) and JavaScript (1995) made automatic GC mainstream, and the industry quietly agreed the trade was worth it: a little unpredictability in when memory is freed, in exchange for never freeing it wrongly. Most code you'll write this decade rests on McCarthy's bet.
How GC works, conceptually: mark-and-sweep
You don't need to implement a collector, but you must know its shape, because it explains the leaks in the next section. The core algorithm is mark-and-sweep, and it runs in two phases. Mark: start at the roots and walk every reference, painting each object you reach as "reachable." Sweep: scan the heap and reclaim everything left unpainted. Simple, cycle-safe, and correct.
V8 (the engine in Chrome and Node) adds a crucial refinement: generational collection. Empirically, most objects die young — a temporary array inside a function is born and abandoned within microseconds. So V8 splits the heap into a small young generation (the "nursery") and a larger old generation. New objects go in the nursery, which is collected very often and very cheaply; the rare survivors get promoted to old space, which is collected less often. You get fast, frequent cleanup where the garbage actually is, without repeatedly re-scanning long-lived data.
What experienced engineers actually hold in their heads about GC:
• You cannot force it. There is no reliable, standard "collect now" API in JS. (Node's --expose-gc flag and global.gc() exist only for testing — never ship code that depends on them.)
• GC pauses are real. A collection briefly stops your code. Modern V8 hides most of it with incremental and concurrent work, but allocating madly in a hot loop still shows up as jank.
• The fix is almost never "optimise the GC." It's "stop keeping things reachable." Leaks are a reachability problem, not a collector problem.
What keeps things alive — and what leaks
A "memory leak" in a GC language sounds like a contradiction, but it's precise: a leak is memory that is still reachable but that you'll never use again. The collector can't help — by its own correct rules, the object is live. You told it so, usually by accident. The usual culprits:
- Closures that capture large objects. A function that closes over a huge array keeps that array alive for as long as the function itself is reachable — even if it never touches the array again.
- Global variables. Anything hanging off
globalThisis rooted forever. An accidental global (a mistyped assignment withoutlet) never dies. - Forgotten timers and listeners. A
setIntervalcallback, or anaddEventListenerhandler, is referenced by the timer/event system — a root you didn't think about. Its captured scope stays alive until youclearInterval/removeEventListener. - Growing caches. A plain object or
Mapyou keep adding to and never trim is a leak wearing a helpful costume.
We give these their own full chapter — "Hunting Memory Leaks" — later in Part VI. For now, hold the through-line: every leak is something a root can still reach.
An early version of the Academy's lesson player set up a setInterval to autosave quiz progress, and the interval's callback closed over the whole lesson object — video state, question bank, the lot. Navigating between lessons in the single-page app created a new player each time but never cleared the old interval. Each abandoned player stayed reachable through its still-ticking timer, so nothing was collected; after twenty lessons the tab was sluggish and bloated. The fix was one line — clearInterval on teardown — which cut the last reference and let the whole graph fall to the collector. A reachability bug, not a GC bug.
WeakMap and WeakSet: references that don't keep things alive
Part IV introduced WeakMap and WeakSet; now their reason for existing is obvious. Their keys are held weakly — a weak reference does not count toward reachability. If the only thing pointing at an object is a WeakMap key, the collector is free to reclaim it, and the entry silently disappears with it. This is exactly what you want for metadata about an object that must not outlive the object itself: cache an object's computed data in a WeakMap, and when the object is collected, its cache entry goes too — no manual cleanup, no leak.
const cache = new WeakMap();
function decorate(el) {
if (!cache.has(el)) cache.set(el, computeExpensiveThing(el));
return cache.get(el);
}
// When `el` is removed and unreferenced elsewhere, its cache entry is
// eligible for GC automatically. A plain Map would keep it alive forever.This is also why WeakMap/WeakSet aren't iterable and have no .size: you can't enumerate a collection whose contents may vanish between one line and the next.
Common Mistakes
- Thinking
= nullfrees memory. It only removes one reference. If any other reference (or a timer, or a closure) still reaches the object, it stays alive. Nulling is a hint, not a delete. - Believing objects are copied on assignment.
const b = afor an object copies the reference. Mutatingbmutatesa. Reach for spread/structuredClonewhen you truly need a copy. - Trying to force collection. There is no portable
gc(). Code that "works after I call gc()" is code with a real leak you're papering over. - Leaving intervals and listeners attached. The single most common front-end leak: components that add handlers on mount and never remove them on unmount.
- Treating a
Mapcache as free. Unbounded caches grow without limit because every entry is reachable. Bound them (LRU, TTL) or useWeakMapwhen the key's lifetime should govern the entry's. - Assuming GC is instantaneous. Unreachable ≠ collected-this-millisecond. It becomes eligible; the engine reclaims it whenever it next runs a collection.
You push ten thousand large objects into an array, then set the array variable to null. Are the objects freed the instant that line runs? No — they become eligible for collection, and the engine reclaims them at some later moment of its choosing. Now change one detail: before nulling the array, you passed one of those objects to setInterval(() => use(obj), 1000). How many of the ten thousand can now be collected? Nine thousand, nine hundred and ninety-nine. A single surviving reference from one root keeps exactly one object alive — and if that object referenced others, they'd survive too. Reachability is all-or-nothing per object, and it follows the chain.
Interview Corner
b = a then b.x = 1, what is a.x?" → 1. Say why: one object, two references.gc(); the engine decides when."WeakMap so the key's lifetime governs the entry. It's a reachability bug; the GC is doing exactly what I told it to."- In the editor above, add a third variable
clone = { ...original }after the mutation, changeclone.balance, and logoriginal.balance. Predict the result before running — then explain why spread broke the sharing that plain assignment kept. - Write a function that creates a
setIntervalcapturing a large array, returns a stop handle, and never leaks — then say out loud (as in a micro1 screen) exactly which reference you cut and which root was holding the array. - Draw the reachability graph for the runnable block at the moment
node = nullruns: mark the roots, the one heap object, and the live edges. Confirm from the drawing why the object survives.
Chapter Summary
- Memory lives in two places: the stack (fixed-size primitives, references, and call frames, cleaned on return) and the heap (objects, arrays, functions, cleaned by GC).
- Primitives are copied by value; objects are held by reference — assignment copies the reference, which is why mutation is shared.
- JavaScript frees memory automatically. An object is kept alive if and only if it is reachable from a root: the global object, the call stack, or a live closure.
- Conceptually the collector runs mark-and-sweep (mark from roots, sweep the rest); V8 layers generational GC on top because most objects die young.
- You cannot reliably force collection — there is no portable
gc(). Setting a variable tonullonly removes one reference. - Leaks are reachable-but-unused memory: closures over large data, globals, forgotten timers/listeners, and unbounded caches.
WeakMap/WeakSethold keys weakly so they never keep an object alive.
We now know where objects live and when they die. But there's a second question hiding in the heap: how does the engine make objects fast? A JavaScript object looks like a free-for-all bag of properties — yet V8 accesses user.name nearly as quickly as a C struct field. The trick is that the engine secretly imposes structure on your shapeless objects. Next, in "Inside V8 (Hidden Classes & Inline Caches)," we open that machinery up — and you'll learn why the order in which you add properties can quietly decide your performance.
Editorial reflection — garbage collection is the rare abstraction that works so well most developers never learn it — until the day memory climbs and won't come down, and the abstraction turns opaque. The engineers who stay calm that day aren't the ones who memorised the collector; they're the ones who kept asking a single childlike question — what, starting from a root, can still reach this? Master that question and you've mastered not just leaks, but the deeper truth that in a managed language, you don't free memory. You stop reaching for it, and trust the engine to notice.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.