Under the Hood

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.

The question this chapter answers

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
Everyday analogy

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.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

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, window in 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.

History changed here

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.

The engineer's notebook

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 globalThis is rooted forever. An accidental global (a mistyped assignment without let) never dies.
  • Forgotten timers and listeners. A setInterval callback, or an addEventListener handler, is referenced by the timer/event system — a root you didn't think about. Its captured scope stays alive until you clearInterval / removeEventListener.
  • Growing caches. A plain object or Map you 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.

Real-world example — chika.ng Academy

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 = null frees 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 = a for an object copies the reference. Mutating b mutates a. Reach for spread/structuredClone when 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 Map cache as free. Unbounded caches grow without limit because every entry is reachable. Bound them (LRU, TTL) or use WeakMap when 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.
Thought experiment

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

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
"What's the difference between how JavaScript stores a primitive and how it stores an object?"
Model answer — "A primitive is stored by value on the stack, so assigning it makes an independent copy. An object lives on the heap, and the variable holds a reference to it. Assigning that variable copies the reference, not the object — so two variables can point at the same object, and mutating through one is visible through the other."
Follow-up they'll throw: "So if I do b = a then b.x = 1, what is a.x?" → 1. Say why: one object, two references.
Intermediate
"JavaScript is garbage-collected — so how does the engine decide what to free, and can you force it?"
Model answer — "It frees anything no longer reachable from a root — the global object, the call stack, or live closures. Conceptually it's mark-and-sweep: mark everything reachable from the roots, sweep the rest. V8 adds generational GC because most objects die young, so the nursery is collected cheaply and often. You can't reliably force it — there's no standard gc(); the engine decides when."
Follow-up: "Does mark-and-sweep handle two objects that reference each other?" → Yes. If no root can reach the cycle, both are unreachable and collected together — that's why it beats naive reference counting.
Senior
"A long-running Node service's memory climbs steadily and never comes back down. Walk me through diagnosing it."
Model answer — "Climbing-and-never-falling says leak, not load — memory that stays reachable but is never used again. I'd take heap snapshots over time and diff them to find which constructor keeps growing, then trace its retainer path back to the root that's holding it. The usual suspects are an unbounded cache, an accidental global, or listeners/timers added without teardown — all of which pin their captured scope. The fix is to cut the reference: bound the cache, remove the listener, or move to a WeakMap so the key's lifetime governs the entry. It's a reachability bug; the GC is doing exactly what I told it to."
The senior move: reframe "GC problem" as "reachability problem," then reach for tooling (heap diffs, retainer paths) rather than guessing.
Company lens — how the same topic gets asked
micro1Live and conversational — the AI interviewer wants you reasoning out loud. Narrate stack-vs-heap and "reference, not copy" as you go; a confident verbal model beats a memorised definition.
GoogleWants precision on the mechanism: define reachability exactly, name the roots, and explain why mark-and-sweep handles cycles that reference counting can't.
MicrosoftValues structured reasoning — trace an object's lifetime cleanly from allocation, through reachability, to eligibility for collection.
Amazon / AWSOperational lens: "your service leaks in production — what do you do?" Tie it to heap snapshots, retainer paths, bounded caches, and cost of a restart-driven mitigation.
Hands-on exercise
  1. In the editor above, add a third variable clone = { ...original } after the mutation, change clone.balance, and log original.balance. Predict the result before running — then explain why spread broke the sharing that plain assignment kept.
  2. Write a function that creates a setInterval capturing 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.
  3. Draw the reachability graph for the runnable block at the moment node = null runs: 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 to null only removes one reference.
  • Leaks are reachable-but-unused memory: closures over large data, globals, forgotten timers/listeners, and unbounded caches. WeakMap/WeakSet hold keys weakly so they never keep an object alive.
Narrative bridge

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.