JavaScript Part 2

Memory, Garbage Collection and WeakMap/WeakRef

Reachability, five usual leaks measured, WeakMap and WeakRef, typed arrays and how to find a leak with heap snapshots.

Lesson 0722 min read#memory#garbage-collection#weakmap

On this page

  1. Words used in this lesson
  2. How the collector decides: reachability
  3. The five usual leaks
  4. Weak references: pointing without holding
  5. Memory outside the JS heap: typed arrays
  6. Finding a leak: the method
  7. Try it
  8. Interview questions
  9. Key takeaways
  10. What's next

In one line: JavaScript frees memory for you, but only for things that nothing can reach any more. A "memory leak" is almost always something you forgot you were still holding: a growing cache, a listener, a timer or a closure. WeakMap and WeakRef let you point at an object without keeping it alive.

In the last lessons you made many objects, closures, listeners and promises. This lesson asks the last question: what happens to all of them when you are finished? You do not call free() in JavaScript. A part of the engine called the garbage collector (GC) does it. To avoid leaks you do not need to know how it is built. You need one idea, reachability, and a few habits.

You should know closures from JavaScript Lesson 10 in the first series, objects from Lesson 0, and event listeners from Lesson 5. For finding leaks in a real single-page app, see Debugging, Lesson 4.

What I ran / checked. Every number below comes from real runs on Node v22.22.0 (Linux, one machine), with node --expose-gc so the script could ask for a collection before each measurement. Memory numbers are rough: they depend on the engine version, the platform and what else ran. Treat them as "this big, not that small", not as exact. I did not test a browser, so the detached-DOM part is explained, not run. The remark that V8 uses a generational collector is general knowledge that I did not re-read in the V8 sources. FinalizationRegistry exists in this Node (checked with typeof) but I did not run a finalizer, on purpose (see below).

Words used in this lesson

  • Garbage collector (GC) is the part of the engine that finds memory nobody can use any more and gives it back.
  • Reachable means you can get to an object by starting from a root (global variables, the current call stack, active timers and listeners) and following references. Reachable objects are kept.
  • Memory leak means memory that your program no longer needs but that is still reachable, so it is never freed. It grows over time.
  • Heap is the memory area for objects. process.memoryUsage().heapUsed shows how much of it is in use.
  • Strong reference is a normal reference. It keeps the object alive.
  • Weak reference is a reference that does not keep the object alive.
  • Retained size is how much memory would be freed if an object were removed.
  • Heap snapshot is a saved picture of every object in memory, with what holds what.

How the collector decides: reachability

Think of your program as a graph. The roots are the starting points. Every object you can reach by following references from a root is alive. Everything else is garbage and may be freed.

TEXT
roots (globals, stack, timers, listeners)   |   +--> cache (Map) --> entry --> big array      reachable: kept   |   +--> handler --> closure scope --> data       reachable: kept   orphan object --> its child                   not reachable: can be freed

The classic simple method is mark and sweep: mark everything reachable from the roots, then sweep away the rest. Real engines are more complex (V8 uses a generational design with young and old areas, according to general knowledge that I did not re-read), but the rule you must remember is the same: no path from a root means it can be freed. A path means it stays, even if you will never use it again.

A first experiment. We build a big array of objects, hold it, then drop the only reference. The script calls global.gc() before each reading so the numbers are not noise:

JavaScript
const mb = (n) => (n / 1024 / 1024).toFixed(1) + " MB";const heap = () => { global.gc(); return process.memoryUsage().heapUsed; };const before = heap();let big = new Array(1_000_000).fill(0).map((_, i) => ({ i }));const during = heap();big = null;                       // drop the only referenceconst after = heap();console.log("before :", mb(before));console.log("holding:", mb(during));console.log("dropped:", mb(after));

Real output (node --expose-gc):

TEXT
before : 3.5 MBholding: 49.3 MBdropped: 11.1 MB

Holding one million small objects costs about 46 MB. After big = null most of it is gone. It does not return to exactly 3.5 MB: the engine keeps some memory for its own use, so compare trends, not single numbers. (I did not investigate exactly what the remaining 7.6 MB is.)

Do not call global.gc() in production code. It exists only when you start Node with --expose-gc, and it is a tool for experiments. The engine decides when to collect.

The five usual leaks

All five have the same shape: something reachable holds data you no longer need. Here they are with real measurements. Each experiment ran gc() before it measured, and "retained" means the extra heap after the work.

1. A cache that only grows

JavaScript
const cache = new Map();function lookupLeaky(id) {  if (!cache.has(id)) cache.set(id, { id, payload: new Array(100).fill(id) });  return cache.get(id);}for (let i = 0; i < 20000; i++) lookupLeaky(i);

A cache is a global variable that holds things on purpose. If you never remove entries, it grows forever. Fix it with a limit (the simplest form is "when it is over N, delete the oldest"):

JavaScript
function lookupBounded(id, limit = 500) {  if (!small.has(id)) {    small.set(id, { id, payload: new Array(100).fill(id) });    if (small.size > limit) small.delete(small.keys().next().value);  }  return small.get(id);}
TEXT
unbounded Map cache, 20000 keys : 17.8 MB retainedbounded cache (limit 500)       : 0.5 MB retained

A Map keeps insertion order, so small.keys().next().value is the oldest key. For a real cache with "least recently used" rules, see the LRU cache in Low-Level Design, Lesson 6.

2. Listeners that are never removed

JavaScript
import { EventEmitter } from "node:events";const bus = new EventEmitter(); bus.setMaxListeners(0);function subscribe(i) {  const data = new Array(1000).fill(i);  bus.on("tick", () => data.length);   // the closure keeps `data` alive}for (let i = 0; i < 5000; i++) subscribe(i);

The emitter holds every handler, and every handler holds its closure. Removing the listener frees all of it:

TEXT
5000 listeners never removed    : 38.9 MB retained, listenerCount = 5000after removeAllListeners        : 0.0 MB retained

Note setMaxListeners(0) in the example: Node normally prints a warning after 10 listeners on one event. That warning is a leak alarm. Do not silence it without checking why you have so many listeners. The same rule holds in the browser: every addEventListener needs a matching removeEventListener (or an AbortSignal, see below) when the element or component goes away.

3. Timers that are never cleared

JavaScript
const timers = [];for (let i = 0; i < 2000; i++) {  const big = new Array(2000).fill(i);  timers.push(setInterval(() => big.length, 1e9));}
TEXT
2000 live intervals             : 31.1 MB retainedafter clearInterval             : 0.3 MB retained

An active timer is a root. Its callback is reachable, so everything the callback captured stays. Clear timers when the work is done.

4. Closures that capture more than you meant

JavaScript
function makeHandlerLeaky() {  const huge = new Array(300_000).fill("x");  return () => huge.length;        // uses `huge`, so the whole array stays}function makeHandlerSmall() {  const huge = new Array(300_000).fill("x");  const n = huge.length;           // keep only the number we need  return () => n;}
TEXT
20 closures keeping a 300k-element array: 45.8 MB20 closures keeping only a number       : 0.0 MB

A closure keeps the variables it uses. If it uses a big object, it keeps the big object. Copy the small value you need and let the big thing go (see closures in JavaScript Lesson 10).

5. Detached DOM nodes (browser only, explained, not run)

If you remove an element from the page but a variable, a cache or a listener still points to it, the element and all its children stay in memory. This is a detached DOM node. It is a very common leak in single-page apps. The usual causes are the same as above: a global array of elements, a listener on window that closes over a removed element, or a timer that touches it. The way to find it is a heap snapshot (next section). Chrome DevTools can filter a snapshot for "Detached" nodes. I describe this from the DevTools workflow and did not run it here.

Weak references: pointing without holding

Sometimes you want to attach data to an object, or watch an object, without keeping it alive. JavaScript has weak tools for this.

WeakMap and WeakSet

A WeakMap is like a Map whose keys must be objects and are held weakly. When the key object is no longer reachable from anywhere else, the entry can be collected with it.

JavaScript
const strong = new Map(); const weak = new WeakMap();let objs = [];for (let i = 0; i < 20000; i++) { const o = { id: i }; objs.push(o); strong.set(o, new Array(50).fill(i)); }// Map: objects still referenced      -> 10.2 MBobjs = null;                          // drop all the key objects// Map after dropping the key objects -> 10.2 MB  (the Map still holds them)let objs2 = [];for (let i = 0; i < 20000; i++) { const o = { id: i }; objs2.push(o); weak.set(o, new Array(50).fill(i)); }// WeakMap: objects still referenced  -> 9.8 MBobjs2 = null;// WeakMap after dropping the keys    -> 0.5 MB  (entries became collectable)

The measured lines (each is "heap growth since the start of that part"):

TEXT
Map with 20000 object keys, objects still referenced: 10.2 MBMap after dropping the key objects                   : 10.2 MB (Map still holds them)WeakMap with 20000 keys, objects still referenced   : 9.8 MBWeakMap after dropping the key objects              : 0.5 MB (entries became collectable)

The ordinary Map kept every key and value alive even though we dropped our own variables. The WeakMap let them go. The price is that a WeakMap is not inspectable: it has no size, no keys(), and you cannot loop over it. I checked:

TEXT
WeakMap has no size, no iteration: undefined undefined

That is on purpose. If you could list the entries, the program could observe when the GC runs, and the result would change between runs.

Good uses: private data for objects (before #private fields existed), caching a computed value for an object (cache.get(node)), or marking DOM elements as "already processed" without leaking them. A WeakSet is the same idea for "have I seen this object?".

WeakRef

A WeakRef holds an object weakly. Call .deref() to get the object, or undefined if it was collected.

JavaScript
let target = { name: "temp" };const ref = new WeakRef(target);console.log("deref while strongly held:", ref.deref()?.name);target = null;                       // drop the strong referenceawait new Promise((r) => setTimeout(r, 20)); global.gc();await new Promise((r) => setTimeout(r, 20));console.log("deref after drop + gc    :", ref.deref());
TEXT
deref while strongly held: tempderef after drop + gc    : undefined

Do not build correctness on the GC. You cannot know when (or whether) an object will be collected. The experiment above forced a collection with --expose-gc to make it visible. In real code, deref() may return the object for a long time or undefined soon. Use WeakRef for optional optimisations (a cache that is nice to have), never for logic that must be right.

FinalizationRegistry lets you run a callback after an object is collected. The same warning applies, even more strongly: the callback may run late, or never (for example when the program exits). I did not run one because any output would only show one lucky timing. Do not use it to release resources that must be released (use try/finally or using patterns for that).

Memory outside the JS heap: typed arrays

Not all memory is on the JS heap. A Uint8Array and its ArrayBuffer keep their bytes outside the object heap, in what Node reports as arrayBuffers:

JavaScript
let buf = new Uint8Array(50 * 1024 * 1024);// heapUsed +0.0 MB | arrayBuffers +50.0 MBbuf = null;// after drop: arrayBuffers +0.0 MB
TEXT
50 MB Uint8Array -> heapUsed + 0.0 MB | arrayBuffers + 50.0 MBafter drop        -> arrayBuffers + 0.0 MB

So if you only watch heapUsed, a leak of buffers (for example a list of uploaded files kept in memory) will not show. Watch rss and arrayBuffers as well. Also remember Node has a heap limit (v8.getHeapStatistics().heap_size_limit; on this machine it printed 8240 MB, which depends on memory and settings) but big buffers do not count against that limit in the same way.

Finding a leak: the method

  1. Notice it. Memory (RSS or heap) rises and never falls back, or the process restarts when it hits a limit.
  2. Reproduce it in a loop (a test that calls the suspect code thousands of times) and print process.memoryUsage() after global.gc() in a script with --expose-gc, as in this lesson. If the number keeps growing after many repeats, it is a leak and not just warm-up.
  3. Take heap snapshots. In Node: node --inspect and DevTools, or v8.writeHeapSnapshot(). I wrote one in a small script, and it was 187.3 MB for a process that had just allocated a lot, so snapshots can be large; do this on a test machine.
  4. Compare two snapshots (before and after the suspect work). Sort by retained size, or look at what grew. Follow the retainers (the chain of references) back to a root. That chain is your leak: a global, a listener, a timer or a closure.
  5. Fix by removing the path, then run the same loop and confirm the number is flat.

For the browser tooling and a full worked case see Debugging, Lesson 4.

Cleaning up with AbortController

A tidy way to remove many listeners at once is an AbortSignal:

JavaScript
const ctrl = new AbortController();window.addEventListener("resize", onResize, { signal: ctrl.signal });window.addEventListener("scroll", onScroll, { signal: ctrl.signal });// when the component is removed:ctrl.abort();   // removes both listeners

(signal as an option of addEventListener is a browser feature, and Node's EventTarget and EventEmitter also support it for some APIs. I did not run this browser example here.)

Try it

  1. Change the lookupBounded limit to 5000 and predict the retained memory before you run it. Does it scale with the limit?
  2. Replace the Map in a leaky cache with a WeakMap keyed by the object that you cache data for. When does that not work? (Hint: what if the key is a string or number?)
  3. Write a script that adds a listener inside a loop 1000 times without removing it, prints bus.listenerCount("tick"), then fix it with bus.off.
  4. Make a closure that uses a big array, then change it to copy only .length. Measure both with process.memoryUsage().heapUsed after global.gc().

Answers. (1) Yes, roughly in proportion: about ten times the limit gives about ten times the retained memory, since each entry costs about the same. (2) A WeakMap needs object keys. Strings and numbers cannot be weak keys and live as long as the map holds them, so use a bounded Map for those. (3) Use bus.off("tick", handler) with the same function reference, or removeAllListeners. (4) The copy-the-length version should retain almost nothing, as in the measured 45.8 MB versus 0.0 MB.

Interview questions

How does garbage collection decide what to free? (Must know)

By reachability. The engine starts from roots (globals, the call stack, active timers and listeners) and follows references. Anything it can reach stays; everything else can be freed. So an object is freed when there is no path to it, not when I stop using it. If something reachable still points to it, it stays, even if my code will never read it again.

What is a memory leak in JavaScript? (Must know)

Memory I no longer need that is still reachable, so it grows over time. It is rarely a bug in the engine. It is almost always a reference I forgot: a cache that never evicts, a listener or timer I never removed, a closure that captured a large object, or a detached DOM node.

Name the common causes of leaks and how you would fix each. (Must know)

Unbounded cache: add a size or time limit. Listeners: remove them when the owner goes away, or use an AbortSignal. Timers: clear them. Closures: capture only the small values needed. Global variables: avoid them or clear them. Detached DOM nodes: remove all references (arrays, listeners, timers) when removing the element. I measured the first three; for example, 5000 forgotten listeners held 38.9 MB and removing them brought it back to about zero.

What is the difference between a Map and a WeakMap? (Must know)

A Map holds its keys strongly: as long as the map is reachable, the keys and values stay. A WeakMap holds keys weakly, and keys must be objects. When the key object is not reachable from anywhere else, the entry can be collected. A WeakMap cannot be iterated and has no size, so I use it to attach data to objects I do not own, without keeping them alive. In my test, after dropping the key objects the Map still held 10.2 MB and the WeakMap fell to 0.5 MB.

Why shouldn't you rely on WeakRef or FinalizationRegistry for important logic? (Senior signal)

Because I cannot know when the collector will run, or if it will before the program ends. deref() can return the object for a long time, and finalizer callbacks may be delayed or skipped. So I use them only for optional optimisations, such as a cache that is nice to have. Anything that must be correct, like closing a file or releasing a lock, uses try/finally or explicit cleanup.

How would you find a leak in a Node service? (Senior signal)

I confirm growth in rss and heapUsed over time, reproduce it with a loop in a test, and run with --expose-gc to take stable readings. Then I take two heap snapshots (with --inspect or v8.writeHeapSnapshot()), compare them, sort by retained size and follow the retainer chain from the growing objects to a root. I fix by cutting that path and check again that memory stays flat. I also watch arrayBuffers, because buffers are outside the normal heap count.

Why can a closure cause a leak? (Senior signal)

A closure keeps the variables it uses alive, and the closure itself is alive as long as something reachable holds it, for example a listener, a timer or a returned function. If it uses a big array, the big array stays. In my test, 20 closures that used a 300k-element array retained 45.8 MB, while 20 closures that kept only the array's length retained about 0. The fix is to copy only what the closure needs.

Why does process.memoryUsage().heapUsed not show the whole story? (Senior signal)

Buffers and typed arrays store their bytes outside the JS object heap. A 50 MB Uint8Array raised arrayBuffers by 50 MB and heapUsed by about 0. So a leak of buffers can hide if I only watch heapUsed. I also look at rss, external and arrayBuffers.

Key takeaways

  • Memory is freed when nothing reachable points to it. A path from a root keeps an object alive, even if you will never use it.
  • A leak is memory you do not need but still hold. The usual suspects are unbounded caches, forgotten listeners and timers, closures that capture too much, and detached DOM nodes.
  • Bound your caches, remove listeners, clear timers, and capture only the values a closure needs.
  • WeakMap and WeakSet hold keys weakly (objects only) and cannot be iterated. WeakRef and FinalizationRegistry are for optional optimisations, never for correctness.
  • Typed arrays and buffers live outside the JS heap. Watch arrayBuffers and rss, not only heapUsed.
  • Find a leak by reproducing it, comparing heap snapshots and following the retainer chain to a root.
  • --expose-gc and global.gc() are for experiments, not production code.

What's next

You have now finished the second JavaScript series: objects, this and classes, promises, modules, iterators, the DOM, errors and Proxy, and memory. Good next steps are the interview questions in Full-Stack Lessons 15 to 17, the TypeScript series starting at TypeScript, Lesson 0, and Debugging, Lesson 4 for a leak hunt in a real app.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series