Memory Management

Mental model: Garbage collection does not free what you stopped using, it frees what nothing can reach, so every leak is a reference you forgot you were still holding.

Level: advanced · about 18 minutes

You never free memory in JavaScript, which sounds like it removes the whole problem. It moves it: instead of asking "did I free this?" you ask "is anything still pointing at this?". Leaks are not allocation mistakes, they are bookkeeping mistakes, and the four shapes they take are entirely learnable.

Stack and heap

STACK (small, fast, automatic)        HEAP (large, managed by the collector)
  frame: render()                       +------------------------+
    count = 3        (a number)         | { name: 'Ada',          |
    user  ---------------------------->  |   tags: [ ... ] }      |
    label = 'hi'     (a string ref) --> | 'hi'                   |
  frame: main()                         +------------------------+
    ...
stack
one frame per call, popped on return. Fixed size, hence "Maximum call stack size exceeded"
heap
everything with an identity: objects, arrays, functions, closures, DOM nodes
a variable
is never the object. It is a reference to one, or a primitive value
let user = { name: 'Ada' };
const cache = [user];
const meta = new Map([['current', user]]);

user = null;

console.log(cache[0]?.name ?? 'gone', meta.get('current')?.name ?? 'gone');

Assigning null to user clears one reference, the variable. The array and the Map still hold their own references to the same heap object, so it is fully reachable and both reads succeed. This is the entire mechanism behind every leak in this lesson: "I set it to null" is not the same as "nothing points at it".

Reachability

The collector starts from a set of roots and walks every reference it can follow. Anything it reaches survives. Anything it does not reach is garbage, whether you consider it in use or not. Nothing else matters: not scope, not delete, not whether the function that created it has returned.

  • the global object (window, globalThis) and everything hanging off it
  • the current call stack, including every live closure scope
  • module-level bindings of every evaluated module
  • the DOM tree that is attached to the document
  • pending timers, intervals, and registered event listeners
  • in-flight promises and their reaction handlers
function makeCycle() {
  const a = { name: 'a' };
  const b = { name: 'b' };
  a.peer = b;
  b.peer = a;      // a points to b, b points to a
  return 'created and immediately unreachable';
}

console.log(makeCycle());
// Both objects reference each other, so their reference counts never hit zero.
// A mark-and-sweep collector starts from the roots, cannot reach either one,
// and frees both. Cycles have not been a leak in JavaScript since IE 8.

A cycle is not a leak. Reference counting would fail here, and reachability does not.

How the collector actually runs

GenerationWhat lives thereAlgorithmCost
young (new space)freshly allocated objectsscavenge: copy the survivors, discard the restsub-millisecond, very frequent
old spaceobjects that survived two scavengesmark, sweep and compactlonger, spread over many steps
large object spacebig arrays and buffersallocated directly, never copiedno copy cost

The practical consequence of generational collection is counter-intuitive: short-lived garbage is nearly free. A function that allocates an object and drops it in the same frame costs almost nothing, because the young generation collector only copies survivors. Long-lived objects are the expensive ones, which is why the immutable style of lesson 15.7 is affordable and why a growing cache is not.

Leak shape one: accidental retention at module scope

Grows forever

// module scope: a root
const seen = [];

export function track(event) {
  seen.push(event);      // never trimmed
}

// also: an accidental global
function calculate() {
  total = 0;             // no declaration:
  // becomes globalThis.total
}

Bounded by design

const MAX = 500;
const seen = [];

export function track(event) {
  seen.push(event);
  if (seen.length > MAX) seen.shift();
}

// and 'use strict' (or a module,
// which is always strict) turns
// the accidental global into a
// ReferenceError

Any collection at module scope is a root that lives as long as the page. If it can grow with user activity, it needs a cap, a TTL, or weak keys. "It only stores small strings" is how a 400MB heap starts.

Leak shape two: forgotten timers and subscriptions

function startPolling() {
  const buffer = new Array(1000).fill('payload');   // captured by the callback
  const id = setInterval(() => {
    buffer[0] = Date.now();                          // keeps buffer alive forever
  }, 1000);
  return () => clearInterval(id);                    // the only escape route
}

const stop = startPolling();
console.log('polling started, buffer is retained by the interval');
stop();
console.log('interval cleared, callback and buffer are now collectable');

// The same shape, three more ways to get it wrong:
// - addEventListener without removeEventListener
// - emitter.on without the matching off
// - an IntersectionObserver or ResizeObserver that is never disconnected

The interval is a root, so it keeps the callback, the closure, and everything captured.

const controller = new AbortController();
const { signal } = controller;

window.addEventListener('resize', onResize, { signal });
element.addEventListener('click', onClick, { signal });
fetch(url, { signal });

// Teardown, in one line, for all three:
controller.abort();

The modern fix: one signal cancels everything, and there is nothing to remember.

Leak shape three: detached DOM nodes

// A model of the problem, without a real DOM.
const document_ = { children: [] };
const cache = new Map();

function addRow(id) {
  const node = { id, children: [], text: 'row ' + id };
  document_.children.push(node);
  cache.set(id, node);          // a second reference, held outside the tree
  return node;
}

addRow(1);
addRow(2);
document_.children.length = 0;  // the rows are removed from the page

console.log('rows in the document:', document_.children.length);   // -> 0
console.log('rows still retained by the cache:', cache.size);      // -> 2
console.log('and still fully readable:', cache.get(1).text);       // -> row 1

cache.clear();                  // or use a WeakMap keyed on the node
console.log('after clearing:', cache.size);                        // -> 0

A JavaScript reference outliving the document is the classic front-end leak.

Leak shape four: closures that capture more than they need

function build() {
  const huge = new Array(10000).fill('data');
  const summary = { count: huge.length };

  // Both functions close over the same environment record.
  return {
    getSummary: () => summary,          // looks harmless
    process: () => huge.join(','),      // this one needs huge
  };
}

const api = build();
console.log(api.getSummary());          // -> { count: 10000 }
// Keeping api alive keeps huge alive, because process still references it.

function buildBetter() {
  const huge = new Array(10000).fill('data');
  const summary = { count: huge.length };
  const joined = huge.join(',').length;   // extract what you need now
  return { getSummary: () => summary, size: () => joined };
  // huge is not referenced by any returned function, so it can be collected
}

console.log(buildBetter().getSummary());   // -> { count: 10000 }

Two closures created together share one scope, so the small one can retain the big data.

Finding a leak with heap snapshots

  1. Reproduce with a repeatable action Open and close the dialog, or navigate to a route and back. You need an action you can perform ten times identically.
  2. Take three snapshots Snapshot, perform the action several times, snapshot, repeat the action, snapshot. Anything that grows across all three is a real leak rather than noise.
  3. Compare, do not browse Switch the view to Comparison against the previous snapshot and sort by delta. You are looking for a constructor whose count keeps rising.
  4. Read the retaining path Select an instance and read the Retainers panel from the object up to a root. The line that surprises you is your bug.
  5. Know the two sizes Shallow size is the object itself. Retained size is everything that would be freed if it went away. Sort by retained size to find the one reference worth cutting.
SymptomLikely shapeFirst thing to check
heap grows on every route changesubscriptions or timerscomponent teardown, AbortController usage
many "Detached" nodes in a snapshotDOM references held in JScaches and maps keyed by element
one huge retained objecta closure over big datathe retainers panel, look for a function scope
slow steady growth under normal useunbounded cache or log arrayevery module-level array or Map
memory fine, page still jankynot a leaklayout thrashing and per-frame work (lesson 15.9)

Interactive visualiser: memory. Enable JavaScript to use it.

Try it yourself

Trace a retaining path by hand

// A tiny heap: named objects with references to each other.
const heap = {
  root: { name: 'root', refs: ['moduleScope', 'domTree'] },
  moduleScope: { name: 'moduleScope', refs: ['cache'] },
  cache: { name: 'cache', refs: ['row42'] },
  domTree: { name: 'domTree', refs: [] },
  row42: { name: 'row42', refs: ['cellA'] },
  cellA: { name: 'cellA', refs: [] },
  orphan: { name: 'orphan', refs: [] },
};

function pathTo(target, from = 'root', seen = new Set()) {
  if (from === target) return [from];
  if (seen.has(from)) return null;
  seen.add(from);
  for (const ref of heap[from].refs) {
    const rest = pathTo(target, ref, seen);
    if (rest) return [from, ...rest];
  }
  return null;
}

console.log(pathTo('cellA')?.join(' -> '));   // -> root -> moduleScope -> cache -> row42 -> cellA
console.log(pathTo('orphan'));                // -> null, unreachable, therefore collectable

// Cut one reference and the whole subtree becomes garbage.
heap.cache.refs = [];
console.log(pathTo('row42'));                 // -> null

Add a second path to the same node and make pathTo return every path, not just the first. Then remove one reference and see which paths survive.

A log that cannot grow

function createLog(max = 3) {
  const entries = [];
  return {
    add(entry) {
      entries.push(entry);
      const dropped = [];
      while (entries.length > max) dropped.push(entries.shift());
      return dropped;
    },
    get all() {
      return entries.slice();
    },
    get length() {
      return entries.length;
    },
  };
}

const log = createLog(3);
console.log(log.add('a'), log.add('b'), log.add('c'));   // -> [] [] []
console.log(log.add('d'));                               // -> [ 'a' ]
console.log(log.all, log.length);                        // -> [ 'b', 'c', 'd' ] 3

Change it to drop the newest entry instead of the oldest and say which behaviour you want for an error log. Then add a byte budget instead of an entry count.

Exercises

Prune a leaking node store

A node is { id, children }. Write createNodeStore() with set(node, value), get(node), has(node), size, clear() and prune(root). prune deletes every entry whose node is not reachable from root by following children, and returns the number of entries it dropped. Reachability must be cycle-safe.

A ring buffer that cannot leak

Write createRingBuffer(capacity). push(value) appends and, when full, drops the oldest entry and returns it (otherwise returns undefined). toArray() returns the contents oldest first, length and capacity report the obvious things, at(i) indexes from the oldest (negative indices count back from the newest), and clear() empties it. A capacity that is not a positive integer throws a RangeError.

Check yourself

Two objects reference each other and nothing else references them. What happens?
they are collected, because the collector works from reachability and cannot reach them — Modern engines mark from roots and sweep whatever they did not mark, so an isolated island of mutual references is garbage regardless of how many arrows it contains. Reference counting (old IE, and Python without its cycle detector) is what made cycles dangerous, and JavaScript has not worked that way for a very long time.
Which one is not a root for the garbage collector?
a variable in a function that has already returned and is not captured by any closure — When a frame pops and nothing captured its variables, those references are gone. The other three are all live roots, which is exactly why forgotten intervals and listeners are the second leak shape: the callback stays reachable, and so does every variable its closure captured.
A heap snapshot shows an object with shallow size 48 bytes and retained size 6 MB. What does that mean?
the object itself is tiny, but it is the only thing keeping 6 MB reachable — Shallow size is the object's own footprint. Retained size is everything that would become collectable if this object disappeared, so a 48 byte cache wrapper can retain megabytes. Sorting by retained size is how you find the single reference worth deleting, rather than the biggest object.
Your app grows by 8 MB every time a modal opens and closes. What is the first thing you check?
whether the modal's subscriptions, timers and observers are torn down on close — Repeatable growth per open-and-close cycle is the signature of missing teardown: a listener, an interval, an observer, or a store subscription that outlives the component and keeps its DOM alive. Allocation volume alone does not accumulate, because short-lived garbage is collected cheaply, and the collector can never be disabled.

Common mistakes

  • Believing x = null frees memory. It only removes one reference out of however many exist.
  • Fearing reference cycles. They have not leaked in JavaScript for over a decade.
  • A module-level array or Map that grows with user activity and is never trimmed.
  • An interval, listener or observer with no teardown path.
  • Caching DOM elements in a Map keyed by id, so removed nodes stay detached but alive.
  • Two closures sharing one scope, where the harmless one keeps the expensive data reachable.
  • Trying to force collection, or writing tests that depend on when it happens.
  • Reading a single heap snapshot. Growth across three is the only reliable signal.
  • Assuming every jank problem is memory. Layout and per-frame work look identical to the user.

Takeaways

  • Primitives live in the frame, objects live in the heap, and variables hold references.
  • The collector frees what it cannot reach from the roots. Cycles are collected fine.
  • Young generation garbage is nearly free, so short-lived allocation is cheap and long-lived retention is not.
  • Leak shape one: unbounded module-level collections and accidental globals.
  • Leak shape two: timers, listeners and subscriptions with no teardown. An AbortSignal fixes many at once.
  • Leak shape three: detached DOM nodes held by JavaScript references.
  • Leak shape four: closures capturing more than they need, especially siblings sharing one scope.
  • Diagnose with three snapshots, the comparison view, retained size and the retaining path.