Skip to content
JavaScript js advanced 5 min read

Memory Management & Leaks

JavaScript manages memory for you: there is no malloc or free. Values are allocated automatically when you create them and reclaimed by a garbage collector (GC) when they become unreachable. That convenience hides a subtle truth — “unused” and “unreachable” are not the same thing. A leak in JS is simply memory that stays reachable long after you stopped needing it, and on long-lived pages or Node servers those leaks accumulate until performance degrades or the process crashes.

The memory lifecycle

Every value goes through three phases: allocate, use, and release. You control the first two by writing code; the engine controls the third. Memory lives in two regions — the stack, which holds fixed-size primitives and references, and the heap, where objects, arrays, closures, and functions are allocated.

function buildUser(name) {
  const id = 42;                 // primitive, lives on the stack
  const user = { id, name };     // object allocated on the heap
  return user;                   // reference escapes; object survives
}

const u = buildUser("Ada");      // `u` keeps the heap object reachable

How garbage collection works

Early engines used reference counting: each object tracked how many references pointed at it, and was freed when the count hit zero. The fatal flaw is cycles — two objects that reference each other never reach zero even when nothing else points to them.

Modern engines (V8, SpiderMonkey, JavaScriptCore) use mark-and-sweep instead. The GC starts from a set of roots (the global object, the current call stack, active closures) and traverses every reachable reference, marking what it finds. Anything left unmarked is unreachable — including cycles — and gets swept away.

StrategyHandles cyclesUsed by
Reference countingNoLegacy engines, some embedded runtimes
Mark-and-sweepYesV8, SpiderMonkey, JSC (all modern browsers + Node)

Reachability is the only thing the GC cares about. If a value is reachable from a root, it stays — no matter how badly you want it gone.

Common leak patterns

Accidental globals

Assigning to an undeclared variable in non-strict mode attaches it to the global object, which is a root that never gets collected.

function leak() {
  count = 0;        // no const/let → becomes globalThis.count
}

Always use 'use strict' (modules are strict by default) so this throws a ReferenceError instead of silently leaking.

Forgotten timers

A setInterval whose callback closes over a large object keeps that object alive for the lifetime of the timer — which is forever unless you clear it.

const cache = new Array(1_000_000).fill("data");
const id = setInterval(() => console.log(cache.length), 1000);

// Later, when the work is done:
clearInterval(id);   // releases the closure, freeing `cache`

Dangling event listeners

A listener holds a reference to its handler and, transitively, everything the handler closes over. Removing the DOM node is not enough if the listener still points at it.

function attach(node) {
  const onClick = () => doWork(node);
  node.addEventListener("click", onClick);
  return () => node.removeEventListener("click", onClick); // cleanup fn
}

Detached DOM nodes

If JavaScript keeps a reference to a DOM element after it has been removed from the document, the whole subtree stays in memory — “detached” but reachable.

let detached = document.getElementById("widget");
detached.remove();      // gone from the page...
// ...but still in the heap because `detached` references it
detached = null;        // now it can be collected

Unbounded caches

A plain object or Map used as a cache grows without limit. Use WeakMap/WeakSet when the key is an object you don’t otherwise own — their references are weak, so entries vanish automatically once the key is collected.

const metadata = new WeakMap();
function tag(obj, info) {
  metadata.set(obj, info);   // no leak: when `obj` dies, the entry dies
}

Finding leaks with DevTools

The Chrome DevTools Memory panel is the primary tool. Heap snapshots capture every object on the heap at a moment in time; comparing two of them reveals what grew.

The reliable workflow is the three-snapshot technique:

1. Open DevTools → Memory → "Heap snapshot". Take snapshot #1.
2. Perform the suspect action repeatedly (e.g. open/close a modal 5x).
3. Take snapshot #2.
4. Repeat the action again, take snapshot #3.
5. Select snapshot #3, switch the dropdown to "Objects allocated
   between Snapshot 1 and 2" — these survived a full cycle and are
   prime leak suspects.

In Node, capture snapshots programmatically and load the .heapsnapshot file into DevTools.

import { writeHeapSnapshot } from "node:v8";

const file = writeHeapSnapshot();
console.log(`Snapshot written to ${file}`);

Output:

Snapshot written to Heap.20260601.123045.12345.0.001.heapsnapshot

For a quick numeric pulse on a Node process, read the resident set and heap usage directly:

const { rss, heapUsed, heapTotal } = process.memoryUsage();
const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
console.log(`rss=${mb(rss)} heapUsed=${mb(heapUsed)} heapTotal=${mb(heapTotal)}`);

Output:

rss=58.4 MB heapUsed=4.2 MB heapTotal=6.0 MB

A steadily climbing heapUsed across requests — that never returns to baseline after GC — is the signature of a real leak. Look at the Retainers tree in a snapshot to see the exact chain of references keeping an object alive; that chain points straight at the line of code you need to fix.

Forcing GC manually is almost never the answer. The fix is to break the retaining reference, not to nag the collector. (You can run Node with --expose-gc and call global.gc() for testing, but never in production logic.)

Best Practices

  • Always run in strict mode (use ES modules) so accidental globals throw instead of leaking.
  • Pair every setInterval/setTimeout, addEventListener, and subscription with an explicit teardown, and call it on unmount or cleanup.
  • Null out references to large objects and detached DOM nodes once you’re done with them.
  • Prefer WeakMap/WeakSet for object-keyed caches and side-tables so entries are collected with their keys.
  • Bound any cache you build with a max size or TTL eviction policy.
  • Profile with the three-snapshot heap technique and read the Retainers tree to locate the offending reference, rather than guessing.
  • Treat a heap that never returns to baseline after GC as a leak, not as normal growth.
Last updated June 1, 2026
Was this helpful?