Photo by Eric Er on Unsplash

On this page

This chapter follows Memory management and garbage collection, which explained that the collector reclaims everything that is not reachable from a root. A leak is therefore a reachability problem: some path from a root still leads to objects you are done with. This page is a troubleshooting procedure for finding that path, in Chrome DevTools and in Node.

Symptom

  • A long-lived tab gets slower over minutes or hours, and eventually crashes.
  • A Node service’s memory climbs between deployments until it restarts with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory, or a container is killed for exceeding its memory limit.
  • Memory grows each time a particular action is repeated: opening and closing a dialog, navigating between routes, handling a request.

Rising memory alone is not a leak. The heap grows until a collection runs, then drops. Over time that draws a sawtooth. In a healthy program, the low points of the sawtooth stay level. In a leaking one, they climb.

Confirm the growth

Measure only after a garbage collection, and repeat the suspect action enough times to separate a trend from noise.

In Chrome DevTools:

  1. Open the Performance panel and enable the Memory checkbox.
  2. Start recording, click the Collect garbage button (the trash can icon), and repeat the action several times, collecting garbage again at the end.
  3. Stop recording and look at the JS Heap line. If it ends clearly higher than it started, and higher with each repetition, something is retained.

In Node, start the process with --expose-gc and measure heapUsed after forced collections:

scripts/measure-heap.mjs
import { runScenario } from './scenario.mjs';
for (let round = 1; round <= 5; round++) {
for (let i = 0; i < 1_000; i++) {
await runScenario();
}
globalThis.gc();
const megabytes = process.memoryUsage().heapUsed / 1e6;
console.log(`round ${round}: ${megabytes.toFixed(1)} MB`);
}
Terminal window
node --expose-gc scripts/measure-heap.mjs

Roughly level numbers mean no leak in that scenario. Numbers that rise by a similar amount each round mean each run of the scenario leaves something behind. Divide the growth by the number of runs to estimate the size per leak.

If heapUsed stays level but the process’s rss grows, the growth is outside the JavaScript heap: buffers, native modules, or the allocator. Heap snapshots will not show it.

Find what is retained

A heap snapshot records every object on the heap, its size, and every reference between objects. DevTools runs a garbage collection before taking one, so a snapshot contains only reachable objects.

The three-snapshot technique isolates what one action leaves behind:

  1. Load the page and perform the action once, so that caches and lazily loaded code are already in place.
  2. In the Memory panel, choose Heap snapshot and take snapshot 1.
  3. Perform the action several times — five is a convenient number.
  4. Take snapshot 2.
  5. Perform the action five more times and take snapshot 3.
  6. Select snapshot 3 and, in the view selector, choose Objects allocated between Snapshot 1 and Snapshot 2.

These are objects created during the first round of repetitions that are still alive after the second round. Anything the action creates and later releases is gone by then; what remains is what the action leaves behind. Sort by Retained Size, and look for counts that are multiples of five.

Two size columns matter:

  • Shallow Size is the object’s own memory.
  • Retained Size is the memory that would be freed if this object became unreachable, including everything only it keeps alive.

A small object with a large retained size — a closure, a component instance, a cache — is usually the one to look at.

To see why an object is alive, select it and read the Retainers pane. Each row is one reference to the object: which property of which object points to it. Expanding a row follows the chain toward a root. Read it from the leaked object upward until you reach the first object that belongs to your code and should no longer refer to it. That reference is the one to remove. Entries named system / Context are closure environments, and (internal array) and similar names are engine internals you can usually skip past.

For DOM leaks, type Detached into the class filter. Elements that are no longer in the document but are still reachable from JavaScript appear as Detached <div>, Detached <li>, and so on. The Retainers pane shows which JavaScript object holds each one.

Take snapshots in Node

The same snapshot format and the same DevTools views work for Node:

  • Start the process with node --inspect app.js, open chrome://inspect in Chrome, and click inspect under the target. The Memory panel then takes snapshots of the Node process.
  • Write a snapshot from code with v8.writeHeapSnapshot(), which returns the file name.
  • Start the process with --heapsnapshot-signal=SIGUSR2 and send that signal (kill -USR2 <pid>) to write a snapshot from a running server.
  • Start the process with --heapsnapshot-near-heap-limit=2 to write up to two snapshots automatically when the heap approaches its limit, which captures the state of a leak just before the crash.

Load the .heapsnapshot files into the Memory panel with Load. Writing a snapshot pauses the process and needs memory comparable to the heap itself, so on a production server, take it from one instance that has been removed from the load balancer.

Common causes and fixes

Most leaks come from a short list of patterns. Each one is a reference that outlives its purpose.

Listeners and subscriptions that are never removed

A listener on a long-lived target — window, document, a store, an event bus, a WebSocket — keeps its callback alive, and the callback keeps everything it closes over. A component that adds a listener when it mounts and does not remove it when it unmounts leaks one copy of itself per mount.

Pass an AbortSignal when adding the listener, and abort it on teardown. One abort() removes every listener registered with that signal:

src/chart.js
export function mountChart(element) {
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('resize', () => redraw(element), { signal });
document.addEventListener('visibilitychange', () => pause(element), { signal });
return () => controller.abort();
}

See Cancellation with AbortController.

Timers that are never cleared

An active setInterval is a root. Its callback, and everything the callback references, stays alive until clearInterval is called. The same applies to a setTimeout chain that reschedules itself. Store the timer ID and clear it in the same teardown that removes listeners.

Caches without a limit

A module-level Map that stores results by request URL, user ID, or query grows forever unless something removes entries. It is not a bug in the collector; the map is reachable, so its contents are too.

Cap the size. A Map iterates in insertion order, so the first key is the oldest, which is enough for a simple least-recently-used cache:

src/cache.js
const MAX_ENTRIES = 500;
const cache = new Map();
export function remember(key, value) {
cache.delete(key);
cache.set(key, value);
if (cache.size > MAX_ENTRIES) {
cache.delete(cache.keys().next().value);
}
}
export function recall(key) {
if (!cache.has(key)) return undefined;
const value = cache.get(key);
remember(key, value);
return value;
}

When the cache key is an object, such as a DOM node or a request object, use a WeakMap instead. Entries then disappear when their key does.

Detached DOM nodes

Removing an element from the document does not free it if JavaScript still refers to it. An array of rows from a previous render, a Map from IDs to elements, or a closure over an element from an old view all keep the element — and its whole subtree — alive as a detached node.

Keep references to elements only as long as they are in the document. Rebuild lookup tables on each render, or key them with a WeakMap by element.

Closures that share an environment

In V8, all functions created in the same scope share one environment object for the variables they capture. If any of them uses a large value, every one of them keeps it alive:

src/report.js
function createFormatter() {
const rows = new Array(1_000_000).fill(0);
const precision = 1;
const debugCount = () => rows.length;
return (value) => value.toFixed(precision);
}

The returned formatter only uses precision, and debugCount is never called or returned. But debugCount captures rows, so rows lives in the shared environment, and the formatter keeps that environment alive. In Node 24, holding 20 formatters retained about 160 MB. Without the debugCount line, the same 20 formatters retained nothing measurable.

Heap snapshots show this as a system / Context retainer holding the large value. Fix it by setting the large variable to null once it has been used, or by moving the code that needs it into a separate function so it is not in the same scope as the long-lived closure. Execution contexts and the call stack explains how closures keep environments alive.

Objects logged to the console

While DevTools is open, every object passed to console.log is kept alive so that you can expand it later. A page that logs request objects or component state in a loop leaks while DevTools is open, and not otherwise, which makes it confusing to diagnose. Clear the console before taking snapshots, and avoid logging large objects in long-running code.

Promises that never settle

A promise that is never resolved or rejected keeps its reaction callbacks, and everything they close over, alive for as long as the promise is reachable. A request without a timeout that hangs, or a promise wrapper around an event that never fires, accumulates one entry per call. Add timeouts, for example with AbortSignal.timeout(ms) for fetch, and reject pending promises on teardown.

Verify the fix

Repeat the measurement from the confirmation step, with the same number of repetitions. Then take the three snapshots again and check that the objects you identified no longer appear in Objects allocated between Snapshot 1 and Snapshot 2. A fix that removes one retainer sometimes reveals a second one along the same chain, so continue until the count of the leaked objects is zero.

Next: Concurrency with workers.

Search articles

Type a word to search published articles.

Open the search page