Command Palette

Search for a command to run...

Hectal
PHASE 14Advanced ~24 min· topic 8 of 9

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.

Follow the references from the rootsdiagram
Rendering diagram…

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.

drop.jswhole filejavascript
let user = { name: "Asha", photos: new Array(1e6) };
user = null; // the object is unreachable now; the GC frees it later

03Spot 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.

leak.jswhole filejavascript
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.

fixed.jswhole filejavascript
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.

closure.jswhole filejavascript
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. 1

    Make a cycle reachable

    In the toy heap, change C: [] to C: ["D"]. Predict the reachable and garbage lists before running.

  2. 2

    Forget the abort

    In the listener example, remove controller.abort(); from the fixed version. Predict its call count now.

  3. 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 type Detached in the class filter. Then run kept = null, force GC with the bin icon and snapshot again.

Code & diagrams

Mark-and-sweep on a toy heap New tab

D and E point at each other, yet they're garbage: nothing reachable points to them. Reference counting would have kept them forever.

Preview

Press Run to see the page.

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' ]
A listener leak and the AbortController fix New tab

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

Press Run to see the page.

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: true
Timers that outlive their widget New tab

A running interval is a root: its callback and everything that callback uses stay alive until `clearInterval`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

leaky timer still ticking: true
fixed timer ticks after stop: 0
Stop caches growing forever: a small LRU New tab

A 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

[ 'c', 'a', 'd' ]
undefined 3

Break 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.

terminal
$ DevTools → Memory → heap snapshot
── what you'll see ──
Hundreds of "Detached HTMLTableRowElement" entries, and the tab's memory keeps rising as rows are added and removed.

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.

terminal
$ the page shows
── what you'll see ──
One key press triggers the handler once per render so far: 1, then 2, then 50 times, and the page gets slower over time.

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) and performance.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, ResizeObserver need disconnect()), WebSocket handlers and third-party widgets keep references. Frameworks provide cleanup hooks (useEffect return, onUnmounted); the bug is forgetting to use them.

Remember this

  1. 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. 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. 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 = a is not a leak once you drop both.

  4. 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. 5

    The four classic leaks in browser code: (1) Event listeners you add to window, document or a long-lived element and never remove: the handler's closure keeps everything it uses alive. (2) Timers: a setInterval you 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. 6

    Fixes follow from the rule: cut the string. Remove listeners (removeEventListener, or pass { signal } from an AbortController and call abort() once to remove them all, see Phase 9 on events), clear timers (clearInterval), set long-lived references to null when 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

01

How does the JavaScript garbage collector decide what to free?

02

Why does mark-and-sweep handle circular references when reference counting doesn't?

03

Name four common sources of memory leaks in browser code and how to fix each.

04

How would you find a memory leak in a web app?

Practice

01

Rewrite function track() { setInterval(() => console.log("tick"), 1000); } so the caller can stop it, and show the stop call.

02

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.