JavaScript WeakMap and WeakSet

A WeakMap is a collection of key/value pairs where every key must be an object, and a WeakSet is a collection of unique objects. Both behave like their familiar cousins, Map and Set, with one crucial difference: they hold their entries weakly. That means the JavaScript engine’s garbage collector is free to reclaim a key (or a WeakSet member) the moment nothing else in your program references it, even though it still technically sits inside the collection. This makes WeakMap and WeakSet the right tool whenever you want to attach extra data to an object without accidentally keeping that object alive forever — which is exactly why they matter for memory-safe caches, private instance data, and object-tagging systems.

Overview: How WeakMap and WeakSet Work

A regular Map holds a strong reference to every key you store in it. As long as the Map itself is reachable, every key inside it is also considered reachable, and the garbage collector will never free that key’s memory — even if your program has long forgotten about it everywhere else. In a long-running application (a server, a single-page app that never reloads), this can quietly become a memory leak: objects pile up inside a Map that nobody else needs anymore.

WeakMap solves this by holding its keys weakly. A weak reference does not, by itself, keep an object alive. If the only remaining reference to an object is the fact that it happens to be a key in a WeakMap, the garbage collector is allowed to collect that object anyway. When it does, the engine automatically removes the corresponding entry from the WeakMap for you. You never see a dangling entry, and you never have to manually clean one up.

WeakSet applies the same idea to a set of values: every member must be an object, and membership does not keep that object alive. Once an object has no other references, it can be collected and silently disappears from the WeakSet.

What happens internally

Engines like V8 typically implement weak collections using a structure sometimes called an ephemeron table. Conceptually, each WeakMap entry is a pair where the value is only considered reachable through the key — if the key becomes unreachable from anywhere else in the program, the whole pair (key and value) becomes eligible for collection in the same garbage-collection pass. This is different from a plain weak reference to a single object; it correctly handles the case where the value itself would otherwise keep other things alive.

Because entries can vanish at any moment during a GC cycle that you don’t control or observe, WeakMap and WeakSet deliberately expose no way to inspect their full contents. There is no .size, no .keys(), .values(), or .entries(), no .forEach(), and they are not iterable with for...of. If iteration were allowed, the result you’d see would depend on exactly when garbage collection last ran — a non-deterministic, engine-specific detail that the language spec intentionally hides from you. This restriction is also a deliberate security/privacy boundary: it prevents code from using timing or enumeration to detect whether some other part of a program still holds a reference to an object.

Keys (and WeakSet members) must be objects — plain objects, arrays, functions, class instances, or (as of ES2023) registered Symbols created with Symbol.for-style registries are still not allowed, but ordinary unique symbols now are in modern engines. Primitives like strings, numbers, and booleans are rejected, because primitives are not individually tracked by the garbage collector the way objects are — the number 5 isn’t a unique piece of memory that can become “unreachable”; every 5 in your program is the same value. Allowing primitive keys would make the entire weak-reference model meaningless.

Syntax

Both collections are created with new and take an optional iterable of initial entries. Their APIs are deliberately small:

Collection Method Description
WeakMap set(key, value) Adds or updates an entry; returns the WeakMap for chaining.
WeakMap get(key) Returns the value, or undefined if the key isn’t present.
WeakMap has(key) Returns true/false.
WeakMap delete(key) Removes the entry; returns true if it existed.
WeakSet add(value) Adds an object; returns the WeakSet for chaining.
WeakSet has(value) Returns true/false.
WeakSet delete(value) Removes the object; returns true if it existed.

Notice what’s missing on purpose: no clear(), no size, no iteration methods, on either collection.

const wm = new WeakMap();
const obj = {};

wm.set(obj, 'some value');
console.log(wm.get(obj));
console.log(wm.has(obj));
wm.delete(obj);
console.log(wm.has(obj));

const ws = new WeakSet();
const item = {};

ws.add(item);
console.log(ws.has(item));
ws.delete(item);
console.log(ws.has(item));

Output:

some value
true
false
true
false

Examples

Example 1: Private instance data with WeakMap

Before the #privateField syntax existed, WeakMap was the standard way to give class instances truly private data that couldn’t be accessed or tampered with from outside the class. The WeakMap itself lives in the module’s closure, keyed by this, so external code has no way to reach it.

const _privateData = new WeakMap();

class Person {
  constructor(name, age) {
    _privateData.set(this, { name, age });
  }

  getName() {
    return _privateData.get(this).name;
  }

  greet() {
    const { name, age } = _privateData.get(this);
    return `Hi, I'm ${name} and I'm ${age} years old.`;
  }
}

const alice = new Person('Alice', 30);
console.log(alice.greet());
console.log(alice.getName());
console.log(_privateData.has(alice));

Output:

Hi, I'm Alice and I'm 30 years old.
Alice
true

Each Person instance is a key in _privateData, and its associated value holds the “real” fields. If alice is ever garbage collected, its private data entry disappears automatically along with it — you never leak memory just because you gave an object private state.

Example 2: Tracking processed objects with WeakSet

A common pattern is marking objects as “already handled” without mutating the object itself (adding a _processed: true property) and without worrying about cleaning the tracking structure up later.

const processedItems = new WeakSet();

function processItem(item) {
  if (processedItems.has(item)) {
    console.log('Already processed, skipping.');
    return;
  }
  processedItems.add(item);
  console.log('Processing item now.');
}

const task = { id: 1, name: 'Build report' };

processItem(task);
processItem(task);

console.log(processedItems.has(task));

Output:

Processing item now.
Already processed, skipping.
true

The first call adds task to the set and processes it; the second call sees it’s already present and skips the work. If task later becomes unreachable everywhere else in the program, its entry in processedItems is cleaned up automatically — no manual bookkeeping required.

Example 3: Object-keyed caching, and WeakMap’s limitations

WeakMap is a natural fit for memoizing expensive work tied to a specific object, such as a parsed config or a DOM node. This example also shows that WeakMap really has no size property.

function expensiveComputation(obj) {
  console.log('Running expensive computation...');
  return Object.keys(obj).length * 100;
}

const cache = new WeakMap();

function getCachedResult(obj) {
  if (cache.has(obj)) {
    return cache.get(obj);
  }
  const result = expensiveComputation(obj);
  cache.set(obj, result);
  return result;
}

const config = { a: 1, b: 2, c: 3 };

console.log(getCachedResult(config));
console.log(getCachedResult(config));
console.log(typeof cache.size);

Output:

Running expensive computation...
300
300
undefined

The first call misses the cache, runs the expensive function, and stores the result. The second call with the same config object hits the cache and skips the computation entirely — notice 'Running expensive computation...' is only logged once. If config ever falls out of scope everywhere else, its cache entry is freed with it; you get caching without a memory leak, but you also can’t ask the cache how many entries it holds, hence typeof cache.size being 'undefined'.

Under the Hood: Garbage Collection Step by Step

It helps to walk through the lifecycle of a single WeakMap entry:

  • You create an object and store it as a key in a WeakMap, along with some associated value.
  • As long as your code (or anything reachable from your code, like a variable, a closure, or another object) still holds a reference to that key object, it stays alive, and the WeakMap entry stays retrievable.
  • You remove every other reference to the object — for example, the variable holding it goes out of scope, or you reassign it to something else.
  • The WeakMap’s reference to that object does not count as a reason to keep it alive. On the object’s next mark-and-sweep pass, the garbage collector determines the object is unreachable from anywhere “real” and collects it.
  • The engine removes the corresponding key/value pair from the WeakMap as part of that same collection. Your program never sees a stale reference, and you get no signal (and no guaranteed timing) for exactly when this happened.

Compare this to the same scenario with a regular Map: step four never happens. The Map keeps its strong reference to the key forever, the object is never collected, and the entry sits in memory for as long as the Map exists — even though nothing else in the program can reach that key anymore. Multiply that by thousands of DOM nodes, request objects, or cached instances over the life of a long-running app, and you have a classic memory leak.

Common Mistakes

1. Using a primitive as a key

WeakMap keys must be objects. Passing a string, number, or boolean throws a TypeError at runtime — this is valid JavaScript syntax, it simply fails when it runs:

const weirdMap = new WeakMap();

try {
  weirdMap.set('username', 'alice');
} catch (error) {
  console.log(error.message);
}

Output:

Invalid value used as weak map key

The fix is either to use a real object as the key (wrap the primitive in an object, or key on an object you already have, like a user record), or to use a regular Map if you genuinely need primitive keys — just be aware a Map won’t garbage-collect its keys for you.

2. Trying to iterate a WeakMap or WeakSet

Because entries can disappear at any time due to garbage collection, neither collection is iterable. Reaching for for...of, spread syntax, or .forEach() throws:

const cache = new WeakMap();
const obj = {};
cache.set(obj, 'data');

try {
  for (const entry of cache) {
    console.log(entry);
  }
} catch (error) {
  console.log(error.message);
}

Output:

cache is not iterable

If you need to enumerate every entry you’ve stored, WeakMap and WeakSet are the wrong tool — use a regular Map or Set instead, and take on the responsibility of clearing entries yourself when they’re no longer needed.

3. Assuming cleanup happens at a precise, predictable moment

Garbage collection timing is intentionally unspecified by the JavaScript spec. You cannot rely on a WeakMap entry disappearing immediately after you drop the last reference to its key, and you should never write logic that depends on when that happens. If you need to run code in response to an object actually being collected, that’s a job for FinalizationRegistry — and even then, the spec explicitly does not guarantee your callback will run promptly, or at all, before the program exits.

Best Practices

  • Reach for WeakMap/WeakSet when you need to associate data with an object’s lifetime — metadata, caches, or “seen” tracking — without risking a memory leak.
  • For simple private fields on your own classes, prefer the native #privateField syntax; reserve WeakMap for attaching data to objects you don’t control, or for supporting older runtimes.
  • Never store primitives as WeakMap keys or WeakSet values — use a plain object wrapper, or switch to Map/Set if primitives are unavoidable.
  • Don’t design logic around iterating a WeakMap or WeakSet; if you find yourself wanting .size or .keys(), that’s a sign you actually want a regular Map or Set.
  • Don’t rely on the exact timing of garbage collection for correctness — treat it purely as a memory-safety mechanism, not a scheduling tool.
  • Watch out for closures that accidentally keep a “weak” key alive elsewhere (for example, storing it in an array or another Map) — a weak reference only helps if it’s genuinely the last reference.

Practice Exercises

  • Exercise 1: Write a function tagAsFavorite(item) and isFavorite(item) backed by a module-level WeakSet called favorites. Tag two different objects and verify a third, untagged object correctly returns false from isFavorite.
  • Exercise 2: Build a small “visit counter” using a WeakMap that maps an object to a number. Write a function recordVisit(obj) that increments the count each time it’s called for the same object, starting at 1. Call it three times on the same object and log the final count.
  • Exercise 3: Explain, in your own words (as a comment), why the following code throws at runtime, and rewrite it so it works: const m = new WeakMap(); m.set(42, 'answer');. What type of key would fix it?

Summary

  • WeakMap stores key/value pairs where keys must be objects and are held weakly — the garbage collector can reclaim them once nothing else references them.
  • WeakSet stores unique objects, also held weakly, useful for tagging or tracking objects without leaking memory.
  • Neither collection is iterable, has a size, or supports clear() — this is intentional, since contents can change due to GC at any time.
  • Primitive values are never allowed as WeakMap keys or WeakSet members; only objects (and, in modern engines, unique symbols) qualify.
  • Use WeakMap/WeakSet for private data, object-tied caches, and “has this been seen” tracking; use Map/Set when you need iteration, primitive keys, or precise control over cleanup.
  • Garbage-collection timing is unobservable and unspecified — never build logic that depends on exactly when an entry disappears.