← Runtimes
Runtime walkthrough 7 questions · 4 languages

Reachability and garbage collection

Unused values are cleaned up for you. This lesson shows exactly which ones, and why the timing is not yours to rely on.

A garbage collector may reclaim a value once no path of references from the running program leads to it. Whether you will use the value again does not decide it, and the collector chooses when to reclaim.

Before you begin

Why this matters

Memory that keeps growing in a long-running page or Node.js program almost always has the same cause: something that lives for the whole run still refers to values you have finished with. Knowing what the engine looks for lets you find that reference instead of guessing.

What this helps you reason about

  • Predict which objects the program can still reach after a given line.
  • Explain why two objects that refer to each other are still cleaned up.
  • Find the cache, array, or listener that keeps an unused value in memory.

In JavaScript you never free memory yourself, and the common picture, that values you are finished with are cleaned up for you, is correct. This lesson makes it exact: which values the engine may reclaim, how a value you no longer use can stay in memory, and why you cannot rely on when it happens.

Already know this? The short version
  • The engine reclaims an object some time after no path of references from a root reaches it: the variables of running calls, and globals. Whether your code will use it again does not matter.
  • Cycles are reclaimed: the engine traces from the roots and does not count references.
  • Leaks in JavaScript are values that something long-lived still reaches: a Map used as a cache, an array that only grows, an event listener never removed, or a closure stored in one of these.
  • WeakMap and WeakRef hold objects without keeping them reachable; FinalizationRegistry callbacks may run late or never, so close files and sockets yourself with try and finally.
  • A heap snapshot shows, for one run, the path of references that keeps an object reachable.

Go to the final check· Go to the practice tickets

Words in this lesson (7)
reference
A value that leads to an object. A variable whose value is an object holds a reference, not a copy of the object, so copying the variable gives a second way to reach the same object.
garbage collector
The part of the JavaScript engine that finds values that are no longer reachable and reuses their memory, at a time it chooses.
root
A starting point that the garbage collector always treats as in use: a variable of a call that is still running, or a global variable.
reachable
A value is reachable when the program can still get to it by following references from the variables of running calls or from global variables.
reference counting
A way to reclaim memory by counting the references to each value and freeing the value when its count reaches zero. Values that refer to each other keep each other’s counts above zero, so counting alone never frees a cycle.
tracing collector
A garbage collector that starts from the roots, marks every value it can reach, and reclaims the rest. Values that refer only to each other are reclaimed too.
memory leak
Memory that stays in use for values the program will never use again. With a garbage collector, it happens when something reachable still refers to those values.

Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.

01 / The common picture

What happens to values you stop using?

You never free memory yourself. The garbage collector, a part of the runtime, reuses the memory of values you are finished with. But how does it know that you are finished with a value?

The collector cannot know what your program will do next. So it does not ask whether a value will be used again. It asks a question it can answer: can the program still reach the value? A value becomes eligible for reclamation, which means its memory may be reused, when no path of references from the running program leads to it.

Values are cleaned up when the program can no longer reach them, not when you stop using them.

Coming from C: There is no free in JavaScript: setting a variable to null removes one reference, and the engine reclaims the object only when no references from a root remain.

02 / Roots

Where does the collector start looking?

The collector starts from a few places that the program can always use. These starting points are called roots. In JavaScript, the roots are the variables of calls that are still running and global variables, which includes top-level variables of a script.

From each root, the collector follows references: from a variable to a list, from the list to the objects in it, from an object to the objects its fields refer to. Everything it finds this way is reachable. Everything else can be reclaimed.

In the program below, setting draft to null removes one path to the notice. board still leads to it, so the notice stays. Only when board refers to a new array does the last path disappear.

main.ts
let board: Notice[] = [];
let draft: Notice | null = { text: "fire drill at 3" };
board.push(draft);
draft = null; // board still leads to the notice
board = []; // now nothing does

A value stays as long as any path from a root leads to it. One remaining path is enough.

Experiment

Follow every path to a value

Clearing draft removes one path to the notice, not the notice itself. The list in board holds a second reference. Once board refers to a new, empty list, no root leads to the old list or to the notice.

Why it matters To decide whether a value can be reclaimed, look for any remaining path from a root, not only at the variable you just cleared.

Predict both lines. After draft = null, can the program still reach the notice? Then add const pinned = board[0]; above board = []; and predict which variable still leads to the notice at the last stop.

type Notice = { text: string };

let board: Notice[] = [];
let draft: Notice | null = { text: "fire drill at 3" };
board.push(draft);
draft = null;
console.log(board.length, board[0].text);
board = [];
console.log(board.length);
What to look for

Watch the variables panel. After draft = null, draft shows null, but board still shows [{text: "fire drill at 3"}], so 1 fire drill at 3 prints. After board = [], no variable shows the notice: nothing the program can still reach leads to it, and 0 prints. After the edit, pinned shows {text: "fire drill at 3"} at the last stop, so the notice is still reachable. Node can stop on the same line more than once; that is normal for this debugger.

Expected output

1 fire drill at 3
0

03 / Cycles

What happens to values that refer to each other?

There are two common ways to reclaim memory. Reference counting keeps a count of the references to each value and frees the value when its count reaches zero. A tracing collector starts from the roots, marks everything it can reach, and reclaims the rest.

They differ on cycles: values that refer to each other, such as a meeting room that refers to its booking and a booking that refers to its room. With counting alone, each still has one reference, from the other, so neither count reaches zero. A tracing collector never reaches them from a root, so it reclaims both.

main.ts
function bookRoom(name: string, team: string): Room {
  const room: Room = { name: name, booking: null };
  const booking: Booking = { team: team, room: room };
  room.booking = booking; // a cycle
  return room;
}

JavaScript engines trace from the roots, so a cycle that no root reaches is reclaimed.

Coming from Python: CPython counts references and frees most objects as soon as the count reaches zero; it also runs a separate cycle collector for cycles. V8 does not count references at all.

Precisely: counting in other languages

Reference counting is common where memory must be released at a predictable moment. Swift counts references automatically, and Rust’s Rc type counts the owners of one value. Neither frees a cycle by itself: Swift code marks one side weak, and Rust code uses Weak for one direction. JavaScript needs neither, because V8 traces.

Experiment

Drop two objects that refer to each other

The collector follows references from the roots. The Cedar room and its booking refer to each other, but no path from a root reaches either of them, so a tracing collector reclaims both. Counting references alone would find one reference to each and keep them.

Why it matters A cycle is not a leak for a tracing collector. Ask whether a root reaches the group, not whether its members refer to each other.

Predict what prints. After the second call, which room objects can the program still reach? Then add const archive = current; above the second call and predict again.

type Room = { name: string; booking: Booking | null };
type Booking = { team: string; room: Room };

function bookRoom(name: string, team: string): Room {
  const room: Room = { name: name, booking: null };
  const booking: Booking = { team: team, room: room };
  room.booking = booking;
  return room;
}

let current = bookRoom("Cedar", "design");
current = bookRoom("Birch", "sales");
console.log(current.name);
What to look for

Inside bookRoom, after room.booking = booking, room shows {name: "Cedar", booking: {team: "design", room: Object}}. The panel writes Object for an object nested deeper than it opens; here that Object is room itself, so the two objects refer to each other. After the second call, current shows the Birch room, and no variable leads to the Cedar room or its booking, although each still refers to the other. Birch prints when the run ends. After the edit, archive still shows the Cedar room at the last stop, so both Cedar objects stay reachable. The top-level frame also lists bookRoom, shown as function bookRoom(name, team): that is how the panel shows a function, not a copy of it.

Expected output

Birch

04 / Unused but reachable

Can a value you no longer use stay in memory?

Reachability has one consequence that surprises people. A value you will never use again stays in memory as long as something reachable still refers to it. This is called a memory leak, even though nothing was forgotten in the usual sense: the program still holds a path to the value.

In JavaScript the long-lived thing is usually a top-level variable or something stored in one: a Map used as a cache, an array that only grows, or a list of event listeners. A listener is a closure, so it also keeps every variable it uses. The fix is always to remove the reference: delete the cache entry, remove the listener, or limit how many entries a collection keeps.

In a browser, look also for a timer started with setInterval and never cleared, a subscription to a store that is never cancelled, and a page element that was removed from the page but is still kept in a variable or a Map (a detached DOM node).

main.ts
const lookups = new Map<number, string[]>();
// ...
lookups.set(floor, names); // kept until deleted
lookups.delete(floor); // now the list can go

An object you have finished with is still in memory if a long-lived collection or listener refers to it. Remove that reference.

Coming from React: An effect that subscribes to anything must return a cleanup function that undoes it: removeEventListener, clearInterval, unsubscribe, or abort() on an AbortController whose signal was passed to addEventListener. Otherwise the listener, and everything it uses, stays reachable after the component is gone.

Precisely: shared closure context, WeakMap, and WeakRef

In V8, the closures created by one call share one context, which holds every variable that any of them uses. So if one closure from a call is kept, the variables that its sibling closures use stay reachable too, even ones the kept closure never reads. A WeakMap holds its keys weakly: an entry does not keep its key reachable, and the entry disappears once nothing else reaches the key. It suits data attached to objects that something else owns. WeakRef holds one object weakly; deref() returns the object, or undefined once it has been reclaimed. Both depend on timing you do not control, so use them for caches that may lose entries, never for program logic.

Experiment

Keep an unused list reachable through a cache

lookups is a top-level variable, so it is a root for the whole run. Every list stored in it stays reachable, whether or not any code will use it again. The garbage collector cannot know that floor 3 is finished.

Why it matters A collection that lives as long as the program, such as a cache or a list of listeners, keeps everything in it reachable. Remove entries when they are finished, or limit how many it keeps.

Predict what prints: after today = visitorsOn(5), is floor 3’s list still reachable? Then add lookups.delete(3); above the console.log line and predict again.

const lookups = new Map<number, string[]>();

function visitorsOn(floor: number): string[] {
  const names = ["Ana on " + floor, "Ben on " + floor];
  lookups.set(floor, names);
  return names;
}

let today = visitorsOn(3);
today = visitorsOn(5);
console.log(today[0], lookups.has(3));
What to look for

After the second call, today shows floor 5’s list, and no variable shows floor 3’s list. But lookups still holds it: lookups shows Map(2) {3 => ["Ana on 3", "Ben on 3"], 5 => […]}, and Ana on 5 true prints when the run ends. visitorsOn’s frame lists lookups too, because the function uses that top-level variable. After the edit, lookups shows Map(1) with only the entry for 5 at the last stop, and Ana on 5 false prints. As in the first experiment, Node may stop on a line more than once.

Expected output

Ana on 5 true

05 / Timing

When is an unreachable value reclaimed?

Reachability decides which values may be reclaimed. The collector decides when. It may reclaim an unreachable value soon, much later, or never in a short program, and the program gets no signal when it happens.

So never rely on the garbage collector to release anything other than memory, such as an open file, a network connection, or a lock. Release those in your own code, at the point where you are finished with them. In JavaScript, call close in a finally block, which runs whether the code before it returns normally or throws.

main.ts
const file = openReport(); // any resource you open
try {
  writeTotals(file);
} finally {
  file.close(); // not left to the garbage collector
}

You choose when to close resources; the collector chooses when to reclaim memory.

Precisely: FinalizationRegistry and gc()

A FinalizationRegistry calls a function some time after a registered object is reclaimed. The specification allows that call to come late or never, for example when the program ends first. Node.js has a gc() function only when started with --expose-gc, and it is meant for tests of the engine. Observed in the stepper: typeof globalThis.gc is "undefined", so no experiment here forces a collection.

06 / The implementation

What may the runtime change?

The language promises only that an unreachable value is never observed again and that reachable values stay usable. How and when memory is reclaimed is the engine’s choice.

V8, the engine in Node.js and Chrome, splits the heap by age. New objects are collected often and cheaply, because most objects become unreachable soon after they are made. Objects that survive several collections move to an older area that is collected less often. Much of the work runs on other threads while your code runs.

An optimizing engine may also treat a variable as no longer in use after its last use, even while its function is still running. Your program’s results do not change; only how soon memory can be reused does.

Precisely: finding a leak in a real program

A heap snapshot, taken in the browser’s developer tools or with node --inspect, lists the objects that are reachable at one moment. For each object, its retainers view shows the path of references from a root that keeps it alive, which is exactly the path this lesson asks you to find. Compare two snapshots taken some time apart to see what keeps growing. A snapshot describes one run in one engine.

07 / Try it

Which values can the program still reach?

Predict the output, then follow every path from a root to each visitor.

Setting signedIn.length = 0 empties the array.

main.ts
type Visitor = { name: string; host: Visitor | null };

const signedIn: Visitor[] = [];
let ana: Visitor | null = { name: "Ana", host: null };
let ben: Visitor | null = { name: "Ben", host: ana };
ana.host = ben;
signedIn.push(ben);
ana = null;
ben = null;
const carl: Visitor = { name: "Carl", host: null };
signedIn.length = 0;
console.log(signedIn.length, carl.name);

What prints? After line 9, which visitors can the program still reach? After line 11?

Reveal the reasoning

0 Carl. After line 9, ana and ben are both null, but Ben is still reachable through signedIn[0], and Ana through Ben’s host. After line 11, signedIn is empty, so no root leads to Ben or Ana. They still refer to each other, but V8 traces from the roots, so it reclaims both, at a time it chooses; counting references alone would keep them. Carl stays reachable through carl.

Practice tickets

  1. GC-101 · The front desk still holds visitors who left → Free
  2. GC-102 · The printer keeps notifying screens that were closed → Sign in · Pro
  3. GC-103 · Each tracking code keeps a whole scanned page in memory → Sign in · Pro

Keep exploring

I/O, buffering, and external state →

Primary references