JavaScript (ES2026) Module 8 – Map, Set and weak collections
WeakMap, WeakSet, WeakRef and FinalizationRegistry
How WeakMap and WeakSet attach data to objects without keeping them alive, why they cannot be listed, and when WeakRef and FinalizationRegistry are safe.
What you will learn
- Store data about objects in a WeakMap or WeakSet without keeping those objects in memory
- Explain which keys weak collections accept and why they have no size and no iteration
- Predict when the target of a WeakRef can disappear, in Node.js and in other engines
- Use WeakRef and FinalizationRegistry only for caches and diagnostics, never for logic that must run
Before you start
What you’ll learn · Before you start
What you will learn
- Store data about objects in a WeakMap or WeakSet without keeping those objects in memory
- Explain which keys weak collections accept and why they have no size and no iteration
- Predict when the target of a WeakRef can disappear, in Node.js and in other engines
- Use WeakRef and FinalizationRegistry only for caches and diagnostics, never for logic that must run
Before you start
On this page
A WeakMap stores data about objects without keeping those objects alive. It holds its keys weakly: once nothing
else in the program refers to a key, the garbage collector may remove the key and the value stored for it. That makes
it the safe way to attach data to an object you do not own, such as a DOM element, a request or a record from a
library, without a memory leak when that object is thrown away. A WeakSet does the same for a set of marked objects.
The price is that neither can be counted, listed or looped over, because their contents depend on when the collector
runs. Two more tools give direct access to that timing: a WeakRef points at one object without keeping it alive, and
a FinalizationRegistry asks for a callback some time after an object has been collected. Both behave differently from
engine to engine and from run to run, so they belong in caches and diagnostics, never in logic that must happen.
WeakMap and WeakSet: data attached to objects
A WeakMap has four methods of a Map, get, set, has and delete, and a WeakSet has add, has and
delete:
// A library counts clicks per element without adding properties to elements it does not own.
const clicks = new WeakMap();
function recordClick(element) {
clicks.set(element, (clicks.get(element) ?? 0) + 1);
}
// Stand-ins for page elements: in a browser these would be DOM elements created by someone else.
const saveButton = { tag: 'button', id: 'save' };
const helpLink = { tag: 'a', id: 'help' };
recordClick(saveButton);
recordClick(saveButton);
recordClick(helpLink);
console.log('save:', clicks.get(saveButton), ' help:', clicks.get(helpLink));
console.log('an equal-looking object:', clicks.has({ tag: 'button', id: 'save' }));
console.log('the element is unchanged:', JSON.stringify(saveButton));
// A WeakSet marks objects: here, orders that passed validation.
const validated = new WeakSet();
const order = { id: 'A-101', total: 499 };
validated.add(order);
console.log('validated:', validated.has(order), ' a copy:', validated.has({ ...order }));
// There is no size, no list of keys and no way to loop over the entries.
console.log('size' in clicks, typeof clicks.keys, typeof clicks[Symbol.iterator]); Output
save: 2 help: 1
an equal-looking object: false
the element is unchanged: {"tag":"button","id":"save"}
validated: true a copy: false
false undefined undefined
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node element-metadata.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
- The data stays off the object. The click counts live in the WeakMap, so the element objects are unchanged: no extra property for other code to trip over, and nothing to clean up when the library is removed.
- Keys are compared by identity, exactly as in a Map: an equal-looking object is a different key.
- A WeakSet marks objects that passed a check, such as validated orders, without a flag on each order.
- There is no
size, noclearand nokeys,values,entriesorforEach, and a weak collection cannot be spread or used infor…of. You can only ask about a key you already hold. - ES2026 adds
getOrInsertandgetOrInsertComputedto WeakMap, which work as they do on a Map and have the same support: Chrome 145, Firefox 144, Safari 26.2 and Node.js 26, according to MDN’s compatibility data, but not Node.js 24, the version these examples run on.
Which values can be weak keys
Only a value with an identity of its own can be held weakly:
// Which values can be the key of a WeakMap (the same rule holds for WeakSet, WeakRef and FinalizationRegistry).
const candidates = [
['an object', {}],
['an array', []],
['a function', () => {}],
["Symbol('id')", Symbol('id')],
['Symbol.iterator', Symbol.iterator],
["Symbol.for('id')", Symbol.for('id')],
["the string 'id'", 'id'],
['the number 42', 42],
['null', null],
];
for (const [label, key] of candidates) {
try {
new WeakMap().set(key, true);
console.log(`${label}: accepted`);
} catch (error) {
console.log(`${label}: ${error.name}`);
}
} Output
an object: accepted
an array: accepted
a function: accepted
Symbol('id'): accepted
Symbol.iterator: accepted
Symbol.for('id'): TypeError
the string 'id': TypeError
the number 42: TypeError
null: TypeError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node weak-keys.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The standard’s rule, CanBeHeldWeakly, accepts objects (arrays and functions are objects) and symbols that were not
registered with Symbol.for. Everything else throws a TypeError. The reason is that a weak entry may disappear only
when nobody can ask for it any more: anyone can write the string 'id', the number 42 or Symbol.for('id') again at
any time, so a weak entry under such a key could never safely be removed. A symbol made with Symbol('id') is
unique, like an object. Well-known symbols such as Symbol.iterator are accepted too, but they live as long as the
program, so a value stored under one is never collected.
Symbols as weak keys
Symbols became valid weak keys in ES2023. MDN’s compatibility data lists them in Chrome 109, Safari 16.4 and Node.js
20.1, but in Firefox only from version 146: before that, Firefox throws a TypeError for a symbol key, so use object
keys where older Firefox versions matter.
Why a weak collection cannot be counted or listed
The garbage collector reclaims every object that the program can no longer reach from its roots, such as global variables and the variables of functions still running. A Map’s entry is an ordinary reference to its key, so the key stays reachable for as long as the Map does. A WeakMap’s entry is not:
What keeps an object in memory, with a Map and with a WeakMap
Text description of the diagram
Two rows of three boxes, one row for a Map and one for a WeakMap.
In the first row, "your code" points with a solid arrow to a Map, and the Map points with a solid arrow to an element. The text under it says that the page has removed the element, but the Map still refers to it, so it stays in memory.
In the second row, "your code" points to a WeakMap, and the WeakMap points to the element with a dashed arrow, which stands for a weak reference; the element's box is dashed too. The text under it says that nothing else refers to the element, so the garbage collector may remove it, and the value stored for it goes with it.
The standard lets an engine remove an entry at any moment after its key has become unreachable, with any delay it
likes, and so it forbids any way of observing a key without presenting it. If a WeakMap had a size or a keys()
method, the program could watch entries vanish and behave differently depending on when the collector happened to
run. To see the difference at all, this example asks V8 for its collector:
// Two objects, one kept as a Map key and one as a WeakMap key, are watched by a FinalizationRegistry while the
// garbage collector runs. Asking V8 for its gc() function is for experiments like this one only (see the text).
import { setFlagsFromString } from 'node:v8';
import { runInNewContext } from 'node:vm';
setFlagsFromString('--expose-gc');
const gc = runInNewContext('gc');
const collected = [];
const registry = new FinalizationRegistry((name) => collected.push(name));
const strong = new Map();
const weak = new WeakMap();
(function attach() {
const first = { name: 'first' };
const second = { name: 'second' };
strong.set(first, 'metadata');
weak.set(second, 'metadata');
registry.register(first, 'the Map key');
registry.register(second, 'the WeakMap key');
})(); // From here on, nothing but the two collections refers to the objects.
await new Promise((resolve) => setTimeout(resolve, 0));
gc();
await new Promise((resolve) => setTimeout(resolve, 20));
console.log('collected:', collected.join(', ') || 'nothing yet');
console.log('the Map still holds', strong.size, 'entry'); Output
collected: the WeakMap key the Map still holds 1 entry
This output changes from run to run: when the collector runs and when a cleanup callback is called are up to the engine; this pattern accepts a run in which nothing was collected in time, and never one in which the Map's key was
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node map-or-weakmap.mjs
After a full collection, the object kept only as a WeakMap key was collected and the Map’s key was not. Two lines of
the example are for experiments only: setFlagsFromString('--expose-gc') and runInNewContext('gc') give a script the
gc() function, which is otherwise switched on with node --expose-gc and called as globalThis.gc(). Node.js lists
the flag among V8’s options, which have no stability guarantee, and its documentation warns that changing V8 flags in
a running program can do unpredictable things, so never ship either line.
One more rule makes WeakMaps safe for the common case where the value refers back to its key, such as a chart object that holds its container element: a value is kept alive only while its key is. The standard says so in its definition of liveness, and the memoiser of the exercise below depends on it whenever a result refers to its argument.
WeakRef: a reference that may come back empty
A WeakRef holds one object (or a symbol that can be held weakly) without keeping it alive. deref() returns the
object, or undefined once it has been collected. The standard adds one promise: an object returned by deref(), or
just passed to new WeakRef, stays alive at least until the code that is running now has finished and control goes
back to the event loop. Code can therefore check deref() once and use the result for the rest of that run:
// A WeakRef to an object that nothing else refers to, read twice in the same job and once in a later one.
let report = { title: 'monthly report' };
const ref = new WeakRef(report);
report = null;
console.log('same job:', ref.deref()?.title ?? 'gone');
setTimeout(() => console.log('next task:', ref.deref()?.title ?? 'gone'), 0); Output
same job: monthly report next task: monthly report
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node same-job.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Version note
Node.js 24.21.0, whose output is shown first, prints the report twice: the standard keeps the target of a new
WeakRef alive until the running code has finished, and no collection happened before the timer fired, so the
unreachable object was still there. The Run button uses QuickJS, which reclaims an object as soon as nothing refers
to it and does not keep a WeakRef’s target until the running code has finished, so it prints gone twice. Code that
uses a WeakRef has to work with either answer.
In your browser, QuickJS (quickjs-emscripten 0.32.0) prints:
same job: gone next task: gone
The most common use is a cache that may forget. Decoded images, parsed documents and other large values can be kept for as long as something else uses them, and made again after that:
// A cache of decoded thumbnails that does not keep them alive: entries are WeakRefs, and a FinalizationRegistry
// removes an entry once its thumbnail has been collected. gc() is asked for as in map-or-weakmap.mjs.
import { setFlagsFromString } from 'node:v8';
import { runInNewContext } from 'node:vm';
setFlagsFromString('--expose-gc');
const gc = runInNewContext('gc');
const nextTurn = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const cache = new Map(); // name → WeakRef of the decoded thumbnail
const cleanup = new FinalizationRegistry((name) => {
if (cache.get(name)?.deref()) return; // a newer thumbnail has taken the entry
cache.delete(name);
console.log(`cleanup: removed the entry for ${name}`);
});
function thumbnail(name) {
const cached = cache.get(name)?.deref();
if (cached) return cached;
console.log(`decoding ${name}`);
const decoded = { name, pixels: new Uint8Array(500_000) };
cache.set(name, new WeakRef(decoded));
cleanup.register(decoded, name);
return decoded;
}
let shown = thumbnail('thumb-1');
console.log('same thumbnail again:', thumbnail('thumb-1') === shown);
shown = null; // the page stops showing it
gc();
console.log('same job, after gc:', cache.get('thumb-1').deref() ? 'still there' : 'gone');
await nextTurn();
gc();
await nextTurn(20);
console.log('later, after gc:', cache.get('thumb-1')?.deref() ? 'still there' : 'gone');
thumbnail('thumb-1'); Output
decoding thumb-1 same thumbnail again: true same job, after gc: still there cleanup: removed the entry for thumb-1 later, after gc: gone decoding thumb-1
This output changes from run to run: whether the collector has reclaimed the thumbnail, and whether the cleanup callback has run, depends on the engine's timing; the pattern accepts both a run in which it was reclaimed and one in which it was not yet
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node weakref-cache.mjs
The run shows each rule at work. The second request reuses the decoded thumbnail. After the page lets go of it, a collection during the same run of code cannot take it, because the WeakRef was dereferenced in that run; on a later turn it can, the cleanup callback removes the dead entry from the cache, and the next request decodes the thumbnail again. A WeakRef cache never decides how long a value lives: that is up to the collector, which may keep values far longer than you expect or drop them sooner, so use it only where making a value again costs time, not correctness.
FinalizationRegistry: cleanup that may never run
new FinalizationRegistry(callback) creates a registry; register(target, heldValue, token) asks it to call
callback(heldValue) some time after target has been collected; unregister(token) cancels that and returns
whether anything was registered with the token. The held value must not be the target itself, which throws a
TypeError, since it would keep the target alive.
The standard is blunt about what it promises: an engine may call the callbacks late or not at all, for example when
the registry itself has been collected or when the program is shutting down. That rules out every piece of work that
must happen, such as closing a file, releasing a database connection or saving a draft. Give those an explicit close()
call that the code that opened the resource is responsible for, and use a registry only to clean up bookkeeping, as
the thumbnail cache does, or to log that a resource was collected without being closed, a useful warning while
debugging.
Which one to use
| Criterion | Map | WeakMap and WeakSet |
|---|---|---|
| Keeps its keys alive | Yes | No |
| Keys | Any value | Objects and unregistered symbols |
| Size and iteration | Yes | No |
| Same result on every engine and run | Yes | Yes: you can only ask about keys you hold |
| When to choose | Data you own and look up by key | Data attached to objects others own |
| Criterion | WeakRef | Finalization registry |
|---|---|---|
| Keeps its target alive | No | No |
| Targets | One object | Any number of objects |
| What it gives you | deref(): the target, or undefined once collected | A callback some time after collection, or never |
| Same result on every engine and run | No | No |
| When to choose | Caches of values you can make again | Tidying up a cache, or warning about leaks |
A WeakMap’s answers never depend on the collector: you can only ask about a key you hold, and while you hold it, its entry is there. That is why WeakMap and WeakSet are everyday tools, while WeakRef and FinalizationRegistry are rare ones. Reach for a WeakMap whenever you attach data to objects you do not control; reach for the other two only when you have measured a memory problem that a cache can solve.
JavaScript & TypeScript Online Compiler Run the weak-key and WeakMap examples in a full editor and try keys of your own.Exercise: a memoiser that keeps nothing alive
Exercise · Medium · JavaScript
Remember results per argument without keeping objects alive
A page formats customer records and product objects with functions that take a while, and it calls them again and again for the same values. Write memoizeByObject(fn), which returns a new function of one argument that remembers what fn returned:
- the first call with a value calls
fn(value)and remembers the result; later calls with the same value return that result without callingfnagain; - objects, arrays and functions are remembered in a
WeakMap, so that remembering a result never keeps an object in memory; - every other value (strings, numbers, booleans,
null,undefined, symbols, BigInts) is remembered in aMap, because aWeakMapcannot hold it; - "the same value" means what
MapandWeakMapsay: the same object, or an equal primitive,NaNincluded; - a result of
undefinedis remembered like any other result.
const shout = memoizeByObject((s) => s.toUpperCase());
shout('chai'); // calls the function: 'CHAI'
shout('chai'); // remembered: 'CHAI', the function is not called again
The starter code calls fn every time. One sample test also checks that objects never go into a Map.
Starter code · memoize.mjs
/**
* Returns a function of one argument that calls fn at most once per argument: objects, arrays and functions are
* remembered in a WeakMap, every other value in a Map.
*/
export function memoizeByObject(fn) {
// Replace this line: it calls fn on every call and remembers nothing.
return (value) => fn(value);
} The sample tests · memoize.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { memoizeByObject } from './memoize.mjs';
/** fn, wrapped so that the test can count its calls. */
function counted(fn) {
const wrapper = (value) => {
wrapper.calls += 1;
return fn(value);
};
wrapper.calls = 0;
return wrapper;
}
test('an object is formatted once', () => {
const format = counted((customer) => `${customer.name} (${customer.city})`);
const memo = memoizeByObject(format);
const asha = { name: 'Asha', city: 'Pune' };
assert.equal(memo(asha), 'Asha (Pune)');
assert.equal(memo(asha), 'Asha (Pune)');
assert.equal(memo(asha), 'Asha (Pune)');
assert.equal(format.calls, 1);
});
test('equal-looking objects are different values', () => {
const format = counted((p) => p.sku);
const memo = memoizeByObject(format);
memo({ sku: 'A1' });
memo({ sku: 'A1' });
assert.equal(format.calls, 2);
});
test('primitives are remembered too, by value', () => {
const shout = counted((s) => String(s).toUpperCase());
const memo = memoizeByObject(shout);
assert.equal(memo('chai'), 'CHAI');
assert.equal(memo('chai'), 'CHAI');
memo(1);
memo('1');
memo(NaN);
memo(NaN);
memo(null);
memo(undefined);
memo(null);
assert.equal(shout.calls, 6);
});
test('a result of undefined is remembered', () => {
const nothing = counted(() => undefined);
const memo = memoizeByObject(nothing);
const order = { id: 7 };
assert.equal(memo(order), undefined);
assert.equal(memo(order), undefined);
assert.equal(memo('x'), undefined);
assert.equal(memo('x'), undefined);
assert.equal(nothing.calls, 2);
});
test('arrays and functions count as objects', () => {
const size = counted((v) => v.length);
const memo = memoizeByObject(size);
const list = [1, 2, 3];
const greet = (name) => name;
assert.equal(memo(list), 3);
assert.equal(memo(list), 3);
assert.equal(memo(greet), 1);
assert.equal(memo(greet), 1);
assert.equal(size.calls, 2);
});
test('objects are never stored in a Map', () => {
const original = Map.prototype.set;
let objectKeys = 0;
Map.prototype.set = function (key, value) {
if ((typeof key === 'object' && key !== null) || typeof key === 'function') objectKeys += 1;
return original.call(this, key, value);
};
try {
const memo = memoizeByObject((v) => typeof v);
memo({});
memo([]);
memo(() => {});
memo('text');
} finally {
Map.prototype.set = original;
}
assert.equal(objectKeys, 0, 'objects, arrays and functions belong in the WeakMap');
}); A hint
Create both caches once, outside the function you return: const byObject = new WeakMap() and const byValue = new Map(). A value is an object when typeof value === 'object' && value !== null, or a function when typeof value === 'function'. Check with has rather than by comparing get with undefined, so that a remembered undefined counts.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Self-check
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Why can’t you iterate over a WeakMap or read its size? Because what it holds depends on the garbage collector. A
WeakMap holds its keys weakly, and once a key is unreachable from the rest of the program, the engine may remove the
entry at any moment, with any delay. If you could count or list the entries, the result would change with the
collector’s timing, so the same program could print different results on different runs or engines. The standard
avoids that by allowing only operations that start from a key you already hold: get, set, has and delete.
For data you need to count or list, use a Map and remove entries yourself when they are no longer needed.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A dashboard keeps a Map from chart container elements to chart objects. After an hour of switching tabs, memory has grown by 300 MB, although removed containers are no longer on the page. Diagnose the leak, fix it, and say whether a WeakMap alone is enough when every chart object also refers to its container.
-
Senior to staff
A database client wraps each connection in an object and relies on a FinalizationRegistry to close the connection when the wrapper is garbage-collected. Load tests pass, but in production the pool of 100 connections runs out at peak traffic. Explain what went wrong and design the cleanup properly.
Key takeaways
- A WeakMap or WeakSet holds its keys weakly: when nothing else refers to a key, the key and its value can be collected, which makes them safe for data attached to objects you do not own.
- Weak keys are objects and symbols not made with
Symbol.for; primitives throw aTypeError. Firefox accepts symbol keys only from version 146. - Weak collections have no
size, noclearand no iteration, so their answers never depend on when the collector runs. - A WeakMap value that refers back to its key does not keep the key alive.
deref()returns the target orundefined; the target of a new or dereferenced WeakRef stays alive until the running code has finished in V8, while QuickJS may reclaim it at once.- FinalizationRegistry callbacks may be late or never run: close resources explicitly, and use a registry only for bookkeeping and leak warnings.
References
- ECMAScript 2026 Language Specification: WeakMap Objects (Ecma International)
- ECMAScript 2026 Language Specification: WeakSet Objects (Ecma International)
- ECMAScript 2026 Language Specification: CanBeHeldWeakly (Ecma International)
- ECMAScript 2026 Language Specification: WeakRef Objects (Ecma International)
- ECMAScript 2026 Language Specification: AddToKeptObjects (Ecma International)
- ECMAScript 2026 Language Specification: FinalizationRegistry Objects (Ecma International)
- ECMAScript 2026 Language Specification: Execution (of WeakRef and FinalizationRegistry targets) (Ecma International)
- ECMAScript 2026 Language Specification: Liveness (Ecma International)
- Proposal: WeakRefs (Ecma International (TC39))
- Command-line API: --expose-gc (Node.js v24) (OpenJS Foundation (Node.js))
- v8.setFlagsFromString (Node.js v24) (OpenJS Foundation (Node.js))
- WeakMap: browser compatibility (MDN Web Docs)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress