WeakMap, WeakSet and WeakRef

Mental model: A normal collection is a hand gripping its keys, so nothing in it can ever be collected. A weak collection watches its keys instead, and lets go the moment the rest of the program does.

Level: advanced · about 16 minutes

Garbage collection works on reachability: an object survives while some chain of references leads to it from a root. A Map is such a chain. Put a DOM node in a Map as a key and that node can never be collected, even after it is removed from the page, because your map is still pointing at it. Weak collections exist to break that chain.

Map                          WeakMap
  root                         root
   |                            |
   v                            v
  map ==(strong)==> node       map --(weak)--> node
                                              ^
   node is reachable            |
   forever                      +-- if nothing else points here,
                                    the node and its entry both go
const meta = new WeakMap();

const user = { name: 'Ada' };
meta.set(user, { lastSeen: 'today', visits: 3 });

console.log(meta.get(user));         // -> { lastSeen: 'today', visits: 3 }
console.log(meta.has(user));         // -> true
console.log(meta.get({ name: 'Ada' })); // -> undefined, keys are compared by identity
console.log(meta.delete(user));      // -> true
console.log(meta.get(user));         // -> undefined

console.log('size' in meta, typeof meta.forEach);   // -> false undefined

The API is deliberately tiny: four methods, no iteration, no size.

const wm = new WeakMap();
const key = { id: 1 };
wm.set(key, 'data');

console.log(wm.has(key), wm.get({ id: 1 }));

Weak collections key on object identity, exactly like Map does. { id: 1 } is a brand new object, so it is not the same key, and the lookup misses. There is no structural comparison anywhere in JavaScript collections, which is why deep-equal caching needs a serialised string key instead.

Keys must be objects (with one 2023 exception)

const wm = new WeakMap();

try { wm.set('a', 1); } catch (e) { console.log('string:', e.constructor.name); }   // -> string: TypeError
try { wm.set(42, 1); } catch (e) { console.log('number:', e.constructor.name); }    // -> number: TypeError

wm.set({}, 1);                    // objects, fine
wm.set(function () {}, 1);        // functions are objects, fine

// ES2023 added unregistered symbols as valid weak keys.
try {
  wm.set(Symbol('local'), 'ok');
  console.log('unregistered symbol: allowed');
} catch (e) {
  console.log('unregistered symbol: not supported here');
}

// A registered symbol is never collectable, so it stays rejected.
try { wm.set(Symbol.for('global'), 1); } catch (e) { console.log('registered symbol: rejected'); }

Primitives are rejected because they have no identity to be weak about.

Use one: private instance data

Convention, not privacy

class Account {
  constructor(n) {
    this._balance = n;
  }
  get balance() {
    return this._balance;
  }
}

const a = new Account(100);
a._balance = -999;        // nothing stopped you
Object.keys(a);           // [ '_balance' ]
JSON.stringify(a);        // {"_balance":-999}

Actually unreachable

const balances = new WeakMap();

class Account {
  constructor(n) {
    balances.set(this, n);
  }
  get balance() {
    return balances.get(this);
  }
}

const a = new Account(100);
Object.keys(a);           // []
JSON.stringify(a);        // {}
// no handle to balances from outside

Class #private fields do the same job with better syntax and better engine support, and they are the right default in 2026. The WeakMap version still matters for two cases: adding private state to objects you did not construct, and code that must run without class field syntax.

const balances = new WeakMap();

class Account {
  constructor(initial = 0) {
    balances.set(this, initial);
  }
  deposit(amount) {
    balances.set(this, balances.get(this) + amount);
    return this;
  }
  get balance() {
    if (!balances.has(this)) throw new TypeError('not an Account');
    return balances.get(this);
  }
}

const acc = new Account(100).deposit(50);
console.log(acc.balance);          // -> 150
console.log(Object.keys(acc), JSON.stringify(acc));   // -> [] {}

const impostor = { deposit: Account.prototype.deposit };
try {
  Account.prototype.balance;
} catch (e) {
  console.log('brand check:', e.message);   // -> brand check: not an Account
}

The WeakMap version, with the brand check that makes it robust.

Use two: metadata on objects you do not own

This is the case WeakMap was designed for. You need to remember something about a DOM node, a request, or a third-party object, and you must not be the reason it stays alive.

const listeners = new WeakMap();

function onDrag(node, handler) {
  listeners.set(node, handler);
  node.addEventListener('pointermove', handler);
}

// Later, the node is removed from the document and dropped from your app state.
// Nothing else references it, so the node, the handler and the WeakMap entry
// all become collectable together. With a Map, all three would leak.

Node metadata that disappears with the node.

const sizeCache = new WeakMap();
let computations = 0;

function deepSize(obj) {
  if (sizeCache.has(obj)) return sizeCache.get(obj);
  computations += 1;
  const size = JSON.stringify(obj).length;
  sizeCache.set(obj, size);
  return size;
}

const config = { a: 1, b: [1, 2, 3] };
console.log(deepSize(config), deepSize(config), computations);   // -> 22 22 1

config.c = 'new';
console.log(deepSize(config), computations);   // -> 22 1  stale, the cache never saw the change

A cache that cannot outlive its keys. Note the mutation caveat.

WeakSet: marking without owning

const seen = new WeakSet();

function visit(node) {
  if (seen.has(node)) return 'already visited';
  seen.add(node);
  return 'first visit';
}

const a = { id: 'a' };
console.log(visit(a), visit(a));   // -> first visit already visited

// Cycle-safe traversal without polluting the objects you walk.
function hasCycle(start) {
  const stack = [start];
  const active = new WeakSet();
  while (stack.length) {
    const node = stack.pop();
    if (typeof node !== 'object' || node === null) continue;
    if (active.has(node)) return true;
    active.add(node);
    stack.push(...Object.values(node));
  }
  return false;
}

const flat = { a: 1, b: { c: 2 } };
const looped = { name: 'loop' };
looped.self = looped;
console.log(hasCycle(flat), hasCycle(looped));   // -> false true

Three methods: add, has, delete. Use it when the answer is a yes or no.

WeakRef: a reference you must ask about

WeakMap makes a key weak. WeakRef makes a single value weak, and hands you a deref() that returns the object or undefined if it has been collected. Every read is a question, and the answer can change between two reads.

let big = { rows: new Array(1000).fill(0) };
const ref = new WeakRef(big);

function report() {
  const target = ref.deref();          // one deref, one local
  if (!target) return 'the object is gone';
  return `still here, ${target.rows.length} rows`;
}

console.log(report());   // -> still here, 1000 rows

big = null;              // the only strong reference is dropped
// The object is now *eligible* for collection, but nothing forces it.
console.log(typeof ref.deref());   // -> 'object' or 'undefined', engine's choice

Correct usage: deref once, into a local, and handle undefined.

FinalizationRegistry: not a destructor

const registry = new FinalizationRegistry((heldValue) => {
  console.log('collected:', heldValue);   // heldValue must NOT be the object itself
});

let socket = { id: 'ws-1' };
registry.register(socket, 'ws-1', socket);   // target, held value, unregister token
socket = null;

// Later, maybe, the callback runs with 'ws-1'.
// registry.unregister(token) cancels it if you clean up first.

The API is small. The caveats are not.

AssumptionReality
the callback always runsno guarantee at all, and never on page unload
it runs soon after the last reference dropsit runs whenever the collector feels like it, possibly minutes later
callbacks run in registration orderorder is unspecified
I can pass the object as the held valuethat keeps it alive forever and the callback can never fire
it is like a C++ destructorit is a best-effort notification for caches and diagnostics
NeedReach for
private state on your own class#private fields
private state on objects you did not createWeakMap
metadata for DOM nodes or requestsWeakMap
has this object been processed?WeakSet
a cache that must not extend lifetimesWeakMap keyed on an immutable object
an optional pointer to a possibly-dead objectWeakRef plus a null check
deterministic release of a file, socket or lockSymbol.dispose and using, never a finalizer
iteration, size, or serialisationMap or Set, and clear entries yourself

Try it yourself

Watch a registry leak

// A "session store" that leaks, modelled with a Map.
const strong = new Map();
function remember(obj, note) {
  strong.set(obj, note);
}

for (let i = 0; i < 5; i++) {
  remember({ id: i, payload: new Array(1000).fill(i) }, 'note ' + i);
}

// Nothing else references those objects, yet:
console.log('entries still held:', strong.size);   // -> 5
console.log('first key still alive:', [...strong.keys()][0].id);

// The same code with weak keys cannot report a size at all,
// because the answer would depend on the garbage collector.
const weak = new WeakMap();
const keep = { id: 'kept' };
weak.set(keep, 'reachable');
weak.set({ id: 'dropped' }, 'unreachable the moment this line ends');
console.log(weak.get(keep));   // -> reachable

Swap the Map for a WeakMap and try to print registry.size. Explain why the line has to be deleted, not fixed.

Deep freeze without touching the objects

function deepFreeze(obj, seen = new WeakSet()) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (seen.has(obj)) return obj;     // cycle guard, no marker property added
  seen.add(obj);
  for (const value of Object.values(obj)) deepFreeze(value, seen);
  return Object.freeze(obj);
}

const config = { db: { host: 'local', opts: { retry: 2 } } };
config.self = config;               // a cycle, which would hang a naive walk

deepFreeze(config);
console.log(Object.isFrozen(config), Object.isFrozen(config.db.opts));   // -> true true
console.log(Object.keys(config.db));                                     // -> [ 'host', 'opts' ]

Make it handle arrays as well as plain objects, then add a frozen counter so you can prove each object is visited exactly once.

Exercises

Genuinely private balance

Write a class Account whose balance lives in a WeakMap, not on the instance. Support new Account(initial = 0), a balance getter, deposit(amount) and withdraw(amount) (both returning this so they chain). A negative or non-finite amount throws a RangeError, and withdrawing more than the balance throws a RangeError. Calling balance on something that is not an Account throws a TypeError. Object.keys(account) must be empty.

A cache that cannot leak

Write memoizeByObject(fn). The returned function takes exactly one object argument and caches the result per object identity in a WeakMap, so fn runs at most once per object. A primitive argument (including null) must throw a TypeError. Also expose .clear(obj) on the returned function, which forgets one entry and returns true if there was one.

Check yourself

Why does WeakMap have no size property?
Because the answer would depend on when the garbage collector last ran, making it non-deterministic — Exposing size would leak collector timing into observable program behaviour, so two runs of the same code could disagree. The spec avoids the question entirely, which is also why there is no keys(), values() or forEach. Any need to enumerate means you want a Map and the manual cleanup that comes with it.
What does this log?
'a' — The second set uses a different object that happens to have the same shape, so it creates a separate entry (which immediately becomes unreachable). Looking up the original key still finds 'a'. Weak collections, like Map, compare keys with the same rules as ===.
Which of these is a safe use of FinalizationRegistry?
Logging a warning that a cache entry was dropped, purely for diagnostics — Finalizer callbacks are best effort: they may run late, in any order, or never (they definitely do not run on page unload). That rules out anything correctness-critical, which is the first, second and fourth options. Diagnostics and cache bookkeeping are fine, because the program is still correct if the callback never fires. Deterministic release belongs to Symbol.dispose and using.
You need to remember a computed value per DOM node, for nodes that come and go as the user navigates. What do you use?
a WeakMap keyed on the node — A WeakMap is the exact shape of this problem: the entry lives while the node does and disappears with it, with no bookkeeping. The Map version works but makes you responsible for a cleanup path you will eventually forget. A data- attribute stringifies everything and is visible in the DOM, and a WeakSet cannot store values at all.

Common mistakes

  • Using a Map where a WeakMap belongs, which turns a cache into a leak.
  • Trying to iterate or count a weak collection. There is no API and that is deliberate.
  • Using a primitive as a weak key. Only objects and unregistered symbols are allowed.
  • Caching by object identity while callers mutate the object, which serves stale results forever.
  • Calling deref() twice and assuming both answers agree.
  • Passing the target object itself as the held value in FinalizationRegistry, which prevents the callback forever.
  • Treating a finalizer as a destructor. If correctness depends on it, the design is broken.
  • Reaching for WeakMap privacy when #private fields would be clearer.

Takeaways

  • Weak collections hold keys without keeping them alive, so entries vanish with their keys.
  • The API is small on purpose: no size, no iteration, no serialisation, because those would expose collector timing.
  • Keys must be objects, functions, or (since ES2023) unregistered symbols.
  • WeakMap gives you private state and node metadata. WeakSet gives you a cycle-safe "have I seen this?".
  • WeakRef.deref() must be called once into a local and checked for undefined.
  • FinalizationRegistry is a best-effort notification, not a destructor. Never depend on it.
  • For deterministic cleanup use Symbol.dispose and using, which is the next lesson.