JavaScript Collections: Map, Set, WeakMap and WeakSet

October 18, 2026 · 6 min read

For years, JavaScript code used plain objects as dictionaries: const counts = {}; counts[word] = (counts[word] || 0) + 1. It mostly works. But objects were designed to describe things with known properties, not to hold arbitrary keys, and the gaps show.

Map and Set (ES2015) are collections built for the job. Their weak siblings, WeakMap and WeakSet, solve a narrower problem: attaching data to objects without keeping those objects alive forever.

const a = { id: 1 }, b = { id: 2 }
const obj = {}; obj[a] = "A"; obj[b] = "B"
Object.keys(obj), obj[a]
 
const map = new Map([[a, "A"], [b, "B"]])
map.get(a), map.size
 
new Set([3, 1, 3, 2, 1])
new Set([1, 2, 3]).intersection(new Set([2, 3, 4]))
node output
Object.keys(obj)['[object Object]']
obj[a]"B" — overwritten

Object keys are always strings (or symbols). Both objects convert to the same string, "[object Object]", so the second assignment overwrote the first.

0 / 3

Problems with objects as maps

Keys become strings. obj[1] and obj['1'] are the same property, and an object used as a key turns into "[object Object]" — so different objects collide.

Inherited keys. A plain object inherits from Object.prototype, so 'toString' in {} is true. Code that checks if (cache[key]) can be fooled by keys like "constructor". (Object.create(null) avoids this, at the cost of being unusual.)

Key order rules. Integer-like keys are listed first, in ascending order, before other keys in insertion order: Object.keys({ 2: 'x', 1: 'y', b: 'z' }) is ['1', '2', 'b'].

Size and iteration take extra work: Object.keys(obj).length, Object.entries(obj).

Map

const visits = new Map()
visits.set('/home', 3)
visits.set('/blog', 1)

visits.get('/home')            // 3
visits.has('/about')           // false
visits.size                    // 2
visits.delete('/blog')

for (const [path, count] of visits) console.log(path, count)
  • Any value can be a key — objects, functions, numbers — compared by identity (SameValueZero: like ===, except NaN matches NaN).
  • Insertion order, always, for every kind of key.
  • size is a property, updated as you go.
  • Frequent adds and deletes are what engines optimise Map for.

Using objects as keys is the big unlock: metadata for DOM nodes, memoisation keyed by an argument object, a graph's adjacency list keyed by node objects. But it comes with a catch, covered below: a Map keeps every key alive for as long as the Map exists.

Set

A Set holds each value at most once, in insertion order:

const tags = new Set(['js', 'ts', 'js'])
tags.size               // 2
tags.has('ts')          // true
tags.add('css')

const unique = [...new Set(array)]   // de-duplicate an array

Membership checks are O(1) on average, compared with O(n) for array.includes. If you're calling includes inside a loop over another array, that's O(n × m) — converting one side to a Set first makes it O(n + m).

Set operations

Sets now have the operations you'd expect, in current browsers and Node 22+:

const a = new Set([1, 2, 3]), b = new Set([2, 3, 4])

a.union(b)              // Set {1, 2, 3, 4}
a.intersection(b)       // Set {2, 3}
a.difference(b)         // Set {1}
a.symmetricDifference(b) // Set {1, 4}
a.isSubsetOf(new Set([1, 2, 3, 4]))   // true

They return new sets and leave the originals unchanged.

Grouping

Object.groupBy and Map.groupBy (ES2024) build the "group items by key" structure in one call:

Object.groupBy([1, 2, 3, 4], (n) => (n % 2 ? 'odd' : 'even'))
// { odd: [1, 3], even: [2, 4] }

Map.groupBy(orders, (o) => o.customer)   // Map keyed by customer object

Use Map.groupBy when the keys aren't strings.

JSON

JSON.stringify(new Map([['a', 1]])) gives "{}" — Maps and Sets don't serialise. Convert explicitly:

JSON.stringify(Object.fromEntries(map))   // '{"a":1}'   (string keys only)
JSON.stringify([...map])                  // '[["a",1]]' (any keys)
JSON.stringify([...set])                  // arrays for sets

new Map(Object.entries(JSON.parse(text)))  // and back

Strong references: why a plain Map can leak

Attach some metadata to a DOM node, or cache a computed value keyed by an object, and a Map seems like the obvious tool. It has a hidden cost: a Map holds a strong reference to every key, so none of its keys can be garbage collected while the map exists — even after every other part of the program has stopped using them.

const cache = new Map();

function attachMetadata(el) {
  cache.set(el, { clickCount: 0 });
}

let button = document.createElement("button");
attachMetadata(button);

button = null; // the only other reference to this element is gone...
// ...but the Map still holds it. The DOM node and its metadata can
// never be garbage collected while `cache` exists.

If the keys' lifetime is tied to something transient — DOM nodes that get removed, request-scoped objects, component instances that unmount — the map grows forever unless you remember to delete every entry by hand.

WeakMap: keys the garbage collector can still reclaim

WeakMap holds its keys weakly: being a key in a WeakMap does not stop an object being garbage collected. Once nothing else references the key, the engine can reclaim it, and the entry disappears with it — no cleanup code required.

const cache = new WeakMap();

function attachMetadata(el) {
  cache.set(el, { clickCount: 0 });
}

let button = document.createElement("button");
attachMetadata(button);

button = null; // nothing else references the element, so it and its
               // WeakMap entry can be collected. No leak.

Same get / set / has / delete API as Map, with constraints that follow from the weak-reference guarantee:

  • Keys must be objects (or non-registered Symbols) — primitives like strings and numbers aren't tracked by identity, so they can't be weakly referenced.
  • Not iterable — no keys(), values(), entries(), no size. If the collection could be enumerated, iterating it would itself keep every key alive. It's a deliberate restriction, not a missing feature.

WeakSet

WeakSet is to Set what WeakMap is to Map: weakly held membership for "have I already processed this object?" checks that shouldn't keep the object alive.

const processed = new WeakSet();

function process(obj) {
  if (processed.has(obj)) return; // already handled
  processed.add(obj);
  // ... do work ...
}

Once obj is unreachable elsewhere, it's collected and silently drops out of processed. There's no removal to forget.

Two WeakMap patterns

Object-keyed memoisation. A cache keyed by argument identity should never be the reason the argument outlives its natural lifetime:

const memoCache = new WeakMap();

function expensiveComputation(config) {
  if (memoCache.has(config)) return memoCache.get(config);
  const result = compute(config);
  memoCache.set(config, result);
  return result;
}

With a Map, every config object ever passed in would live as long as the process. With a WeakMap, each cache entry dies with its argument.

Private state, before #fields. External code has no handle to the map, so there's no way to read the data:

const _private = new WeakMap();

class Counter {
  constructor() { _private.set(this, { count: 0 }); }
  increment() { _private.get(this).count++; }
  get value() { return _private.get(this).count; }
}

New code should prefer #count class fields, which give the same privacy as built-in syntax. But the pattern shows why WeakMap fits: an instance's private state shouldn't outlive the instance.

Choosing a collection

You needUse
A record with known fields, or JSON in and outPlain object
A dictionary with dynamic or non-string keys, frequent changes, iterationMap
"Have I seen this value?" — de-duplication, fast membershipSet
Metadata or a cache keyed by objects with their own lifetimeWeakMap
Tracking which objects you've already handled, without keeping them aliveWeakSet
To iterate, count, or use primitive keysMap / Set — the weak versions can't

Small, fixed lookup tables written as literals — const STATUS_TEXT = { 404: 'Not found', 500: 'Server error' } — are still objects, and TypeScript types them precisely. Reach for Map when keys are dynamic, aren't strings, or the collection changes often; Set whenever the question is "have I seen this?"; and the weak versions when the keys are objects that should be free to go.