Introduction
JavaScript provides four powerful collection types beyond plain objects and arrays: Map, Set, WeakMap, and WeakSet. Each serves distinct purposes and offers unique performance characteristics for specific use cases. Understanding these collections deeply — their internal mechanics, performance tradeoffs, and ideal use cases — separates competent JavaScript developers from experts.
This guide explores when to use each collection, their internal mechanics, performance benchmarks, and practical patterns for real-world applications. You will learn not just the API surface but the underlying implementation details that explain why certain collections outperform others in specific scenarios.
Understanding JavaScript Collections: Core Concepts
Map vs Object: The Fundamental Difference
The most common question developers face is when to use a Map versus a plain object. The answer lies in understanding their internal implementations and the constraints each imposes.
Object keys are always coerced to strings or symbols. This implicit coercion causes subtle bugs:
// Object keys are always strings/symbols
const obj = {};
obj[1] = 'one';
obj['1'] = 'ONE'; // Overwrites the number key!
console.log(obj); // { '1': 'ONE' }
// Demonstration of the coercion problem
const cache = {};
cache[{ id: 1 }] = 'value';
console.log(cache[{ id: 1 }]); // undefined — different toString() result
console.log(cache['[object Object]']); // 'value' — the actual key usedMap eliminates this problem entirely. Keys in a Map can be any type — numbers, strings, booleans, objects, functions, even other Maps and Sets:
const map = new Map();
map.set(1, 'number one');
map.set('1', 'string one');
map.set(true, 'boolean key');
map.set(undefined, 'undefined key');
map.set(null, 'null key');
map.set(NaN, 'NaN key');
map.set(() => {}, 'function key');
map.set({ id: 1 }, 'object key');
map.set(Symbol('test'), 'symbol key');
console.log(map.get(1)); // 'number one'
console.log(map.get('1')); // 'string one'
console.log(map.size); // 9 — all distinct keysNotice that even NaN works as a key, and map.get(NaN) returns the correct value — something impossible with objects since NaN !== NaN in JavaScript.
Internal Implementation: Hash Tables Under the Hood
Both Map and Set use hash table implementations internally, but with optimizations that plain objects lack. When you call map.set(key, value), the engine computes a hash of the key and uses it to determine the storage bucket. This gives O(1) average-case lookup time.
V8 (the engine powering Chrome and Node.js) uses a tiered strategy for Map storage:
- Small Maps (< ~12 entries): Uses a linear array for fast iteration
- Medium Maps: Switches to a hash table when the threshold is exceeded
- Large Maps: Uses a hash table with optimized collision handling
This means that for small collections, Map may actually be faster than a plain object because the engine can optimize the internal representation. For large collections with frequent insertions and deletions, Map maintains consistent performance while plain objects may degrade due to hidden class transitions in V8.
Iteration Order Guarantees
Map and Set guarantee insertion-order iteration — a critical property that plain objects lacked until ES2015 (and still handle inconsistently for integer-like keys):
const map = new Map();
map.set('c', 3);
map.set('a', 1);
map.set('b', 2);
// Guaranteed insertion order
for (const [key, value] of map) {
console.log(key, value); // c 3, a 1, b 2
}
// Object comparison — integer keys are sorted first!
const obj = { c: 3, a: 1, b: 2, 0: 'zero' };
console.log(Object.keys(obj)); // ['0', 'c', 'a', 'b']This ordering guarantee makes Map ideal for scenarios where insertion order matters, such as LRU caches, ordered registries, and configuration priority systems.
Map: Deep Dive
Complete API Surface
Map provides a rich set of methods for managing key-value pairs:
const map = new Map();
// Core operations
map.set('key', 'value'); // Returns the Map (chainable)
map.get('key'); // Returns 'value' or undefined
map.has('key'); // Returns boolean
map.delete('key'); // Returns boolean (true if existed)
map.clear(); // Removes all entries
// Size
map.size; // Number of entries
// Iteration methods
map.keys(); // Iterator of keys
map.values(); // Iterator of values
map.entries(); // Iterator of [key, value] pairs
map.forEach((value, key, map) => { // Callback iteration
console.log(key, value);
});
// Construction from iterables
const fromArray = new Map([['a', 1], ['b', 2]]);
const fromObject = new Map(Object.entries({ a: 1, b: 2 }));
const fromAnotherMap = new Map(existingMap);
const fromSet = new Map([...set].map(v => [v, v * 2]));Chaining and Fluent APIs
Map's set() method returns the Map itself, enabling fluent chaining:
const config = new Map()
.set('host', 'localhost')
.set('port', 3000)
.set('debug', true)
.set('maxRetries', 3);Map with Complex Keys
The real power of Map shines when using objects as keys:
const componentState = new Map();
// Use DOM elements as keys
const header = document.querySelector('header');
const footer = document.querySelector('footer');
componentState.set(header, { scrollY: 0, isVisible: true });
componentState.set(footer, { lastUpdated: Date.now() });
// Use class instances as keys
class EventEmitter {
constructor() {
this.handlers = new Map();
}
on(event, handler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event).add(handler);
return this;
}
emit(event, ...args) {
if (this.handlers.has(event)) {
for (const handler of this.handlers.get(event)) {
handler(...args);
}
}
return this;
}
}Set: Deep Dive
Unique Value Collection
Set stores unique values and provides O(1) lookup time, making it far superior to Array.includes() for large collections:
// Deduplication
const numbers = [1, 2, 3, 2, 1, 4, 5, 4, 3];
const unique = [...new Set(numbers)];
console.log(unique); // [1, 2, 3, 4, 5]
// Performance comparison for membership testing
const largeArray = Array.from({ length: 100000 }, (_, i) => i);
const largeSet = new Set(largeArray);
// Array.includes: O(n) — scans linearly
console.time('array');
largeArray.includes(99999);
console.timeEnd('array'); // ~0.1ms
// Set.has: O(1) — hash lookup
console.time('set');
largeSet.has(99999);
console.timeEnd('set'); // ~0.001msSet Operations: Union, Intersection, Difference
Modern JavaScript (ES2025+) introduces built-in Set methods, but here are both the modern and polyfill approaches:
const setA = new Set([1, 2, 3, 4, 5]);
const setB = new Set([4, 5, 6, 7, 8]);
// === ES2025+ Built-in Methods ===
const union = setA.union(setB); // {1,2,3,4,5,6,7,8}
const intersection = setA.intersection(setB); // {4,5}
const difference = setA.difference(setB); // {1,2,3}
const symmetricDiff = setA.symmetricDifference(setB); // {1,2,3,6,7,8}
const isSubset = setA.isSubsetOf(setB); // false
const isSuperset = setA.isSupersetOf(setB); // false
const hasOverlap = setA.intersection(setB).size > 0; // true
// === Polyfill / Pre-ES2025 ===
function setUnion(a, b) {
return new Set([...a, ...b]);
}
function setIntersection(a, b) {
return new Set([...a].filter(x => b.has(x)));
}
function setDifference(a, b) {
return new Set([...a].filter(x => !b.has(x)));
}
function setSymmetricDifference(a, b) {
return new Set([...a].filter(x => !b.has(x)).concat([...b].filter(x => !a.has(x))));
}Real-World Set Patterns
// Tag filtering system
const articles = [
{ id: 1, title: 'Intro to JS', tags: ['javascript', 'beginner'] },
{ id: 2, title: 'Advanced CSS', tags: ['css', 'advanced'] },
{ id: 3, title: 'React Patterns', tags: ['javascript', 'react', 'advanced'] },
];
function filterByTags(articles, requiredTags) {
const required = new Set(requiredTags);
return articles.filter(article => {
const articleTags = new Set(article.tags);
return [...required].every(tag => articleTags.has(tag));
});
}
// Permission checking
class PermissionChecker {
constructor() {
this.roles = new Map(); // userId -> Set<permission>
}
assignRole(userId, permissions) {
this.roles.set(userId, new Set(permissions));
}
hasPermission(userId, permission) {
return this.roles.get(userId)?.has(permission) ?? false;
}
hasAnyPermission(userId, permissions) {
const userPerms = this.roles.get(userId);
if (!userPerms) return false;
return permissions.some(p => userPerms.has(p));
}
}WeakMap: Memory-Safe Object Metadata
The Garbage Collection Problem
When you use a regular Map with object keys, the Map holds a strong reference to those objects, preventing garbage collection even when nothing else references them. In long-running applications (SPAs, servers), this causes memory leaks:
// Memory leak with regular Map
const metadata = new Map();
function processElement(element) {
metadata.set(element, { processed: true, timestamp: Date.now() });
// Even after 'element' is removed from the DOM,
// the Map keeps it alive in memory!
}
// Memory-safe with WeakMap
const safeMetadata = new WeakMap();
function processElementSafe(element) {
safeMetadata.set(element, { processed: true, timestamp: Date.now() });
// When 'element' is garbage collected, this entry disappears automatically
}WeakMap only accepts object keys (no primitives), and its references are weak — if no other reference to the key object exists, the garbage collector can reclaim the entry.
Private Data Pattern
WeakMap is the canonical way to implement truly private instance variables in JavaScript classes:
const _private = new WeakMap();
class BankAccount {
constructor(owner, balance) {
_private.set(this, { owner, balance, transactions: [] });
}
get balance() {
return _private.get(this).balance;
}
deposit(amount) {
const data = _private.get(this);
data.balance += amount;
data.transactions.push({ type: 'deposit', amount, date: new Date() });
return this;
}
withdraw(amount) {
const data = _private.get(this);
if (amount > data.balance) throw new Error('Insufficient funds');
data.balance -= amount;
data.transactions.push({ type: 'withdrawal', amount, date: new Date() });
return this;
}
getTransactionHistory() {
return [..._private.get(this).transactions];
}
}
const account = new BankAccount('Alice', 1000);
account.deposit(500).withdraw(200);
console.log(account.balance); // 1300
// No way to access _private from outside!DOM Element Metadata
One of the most practical WeakMap use cases is attaching metadata to DOM elements without polluting them:
const elementData = new WeakMap();
function trackElement(element) {
elementData.set(element, {
clicks: 0,
lastInteraction: null,
isVisible: false,
observer: new IntersectionObserver(([entry]) => {
elementData.get(element).isVisible = entry.isIntersecting;
}),
});
elementData.get(element).observer.observe(element);
element.addEventListener('click', () => {
const data = elementData.get(element);
data.clicks++;
data.lastInteraction = Date.now();
});
}
// When element is removed from DOM and no JS references it,
// the WeakMap entry is automatically garbage collectedWeakSet: Object-Only Unique Tracking
Circular Reference Detection
WeakSet excels at tracking visited objects during deep operations:
const visited = new WeakSet();
function deepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj;
if (visited.has(obj)) {
// Circular reference detected — return reference instead of infinite loop
return obj;
}
visited.add(obj);
const clone = Array.isArray(obj) ? [] : {};
for (const [key, value] of Object.entries(obj)) {
clone[key] = deepClone(value);
}
return clone;
}
// Works with deeply nested circular structures
const data = { a: 1 };
data.self = data; // Circular reference
const cloned = deepClone(data); // Doesn't stack overflowObject Pool Management
class ObjectPool {
constructor(factory, reset, maxSize = 100) {
this.factory = factory;
this.reset = reset;
this.maxSize = maxSize;
this.available = [];
this.inUse = new WeakSet();
}
acquire() {
let obj;
if (this.available.length > 0) {
obj = this.available.pop();
} else {
obj = this.factory();
}
this.inUse.add(obj);
return obj;
}
release(obj) {
if (!this.inUse.has(obj)) return false;
this.inUse.delete(obj);
this.reset(obj);
if (this.available.length < this.maxSize) {
this.available.push(obj);
}
return true;
}
get isAvailable() {
return this.available.length > 0;
}
}
// Usage: DOM element pool
const buttonPool = new ObjectPool(
() => document.createElement('button'),
(btn) => {
btn.textContent = '';
btn.className = '';
btn.onclick = null;
}
);Performance Benchmarks: Choosing the Right Collection
Lookup Performance
const iterations = 1_000_000;
// Build test data
const obj = {};
const map = new Map();
const arr = [];
const set = new Set();
for (let i = 0; i < iterations; i++) {
obj[i] = i;
map.set(i, i);
arr.push(i);
set.add(i);
}
// Object property access
console.time('Object lookup');
for (let i = 0; i < iterations; i++) obj[i];
console.timeEnd('Object lookup');
// Map.get
console.time('Map lookup');
for (let i = 0; i < iterations; i++) map.get(i);
console.timeEnd('Map lookup');
// Array.includes (worst case — O(n))
console.time('Array.includes');
for (let i = 0; i < 10000; i++) arr.includes(iterations - 1 - i);
console.timeEnd('Array.includes');
// Set.has
console.time('Set.has');
for (let i = 0; i < 10000; i++) set.has(iterations - 1 - i);
console.timeEnd('Set.has');Insertion and Deletion Performance
// Map vs Object for frequent mutations
const mapMut = new Map();
const objMut = {};
console.time('Map set+delete');
for (let i = 0; i < 100000; i++) {
mapMut.set(i, i);
if (i % 3 === 0) mapMut.delete(i - 1);
}
console.timeEnd('Map set+delete');
console.time('Object set+delete');
for (let i = 0; i < 100000; i++) {
objMut[i] = i;
if (i % 3 === 0) delete objMut[i - 1];
}
console.timeEnd('Object set+delete');
// Map typically 2-5x faster for frequent mutations in V8Summary Table
| Operation | Map | Object | Set | Array |
|---|---|---|---|---|
| Key types | Any | String/Symbol | Any value | N/A (index) |
| Lookup | O(1) | O(1) | O(1) | O(n) |
| Insert | O(1) | O(1)* | O(1) | O(1) amortized |
| Delete | O(1) | O(1)* | O(1) | O(n) |
| Iteration | Insertion order | Unstable** | Insertion order | Index order |
| Size | .size O(1) | Object.keys().length O(n) | .size O(1) | .length O(1) |
| Garbage collected keys | No | No | No | N/A |
* Objects may trigger hidden class deoptimizations in V8 when frequently adding/deleting properties. ** Object integer keys are sorted numerically; string keys follow insertion order.
Advanced Patterns
LRU Cache with Map
Map's insertion-order guarantee enables a simple, efficient LRU cache:
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) return undefined;
// Move to end (most recently used)
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
put(key, value) {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
// Evict oldest entry (first in iteration order)
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
}
const lru = new LRUCache(3);
lru.put('a', 1);
lru.put('b', 2);
lru.put('c', 3);
lru.get('a'); // 1, 'a' moves to end
lru.put('d', 4); // Evicts 'b' (oldest)
console.log(lru.get('b')); // undefinedMemoization with Map
function memoize(fn) {
const cache = new Map();
return function (...args) {
// Create a stable key from arguments
const key = args.length === 1 ? args[0] : JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// For functions with object arguments, use a WeakMap
function memoizeObjects(fn) {
const cache = new WeakMap();
return function (obj) {
if (cache.has(obj)) return cache.get(obj);
const result = fn.call(this, obj);
cache.set(obj, result);
return result;
};
}EventEmitter with Map and Set
class EventEmitter {
constructor() {
this.listeners = new Map();
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event).add(callback);
// Return unsubscribe function
return () => {
this.listeners.get(event)?.delete(callback);
if (this.listeners.get(event)?.size === 0) {
this.listeners.delete(event);
}
};
}
once(event, callback) {
const unsubscribe = this.on(event, (...args) => {
unsubscribe();
callback(...args);
});
return unsubscribe;
}
emit(event, ...args) {
if (!this.listeners.has(event)) return false;
for (const callback of this.listeners.get(event)) {
callback(...args);
}
return true;
}
removeAllListeners(event) {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
}
}State Management Store
class ReactiveStore {
constructor(initialState = {}) {
this.state = new Map(Object.entries(initialState));
this.listeners = new Map();
this.computedCache = new Map();
}
get(key) {
return this.state.get(key);
}
set(key, value) {
const oldValue = this.state.get(key);
if (oldValue === value) return;
this.state.set(key, value);
this.computedCache.clear(); // Invalidate computed values
// Notify subscribers
if (this.listeners.has(key)) {
for (const listener of this.listeners.get(key)) {
listener(value, oldValue, key);
}
}
// Notify wildcard subscribers
if (this.listeners.has('*')) {
for (const listener of this.listeners.get('*')) {
listener(value, oldValue, key);
}
}
}
subscribe(key, listener) {
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key).add(listener);
return () => this.listeners.get(key).delete(listener);
}
// Computed values with caching
compute(key, deps, computeFn) {
const depValues = deps.map(d => this.state.get(d));
const cacheKey = `${key}:${depValues.join(',')}`;
if (this.computedCache.has(cacheKey)) {
return this.computedCache.get(cacheKey);
}
const result = computeFn(...depValues);
this.computedCache.set(cacheKey, result);
return result;
}
}Common Pitfalls and Solutions
Pitfall 1: Object Key Coercion
// Wrong: Object key coercion
const cache = {};
cache[{ id: 1 }] = 'value';
console.log(cache[{ id: 1 }]); // undefined — different toString()!
// Right: Use Map
const map = new Map();
const key = { id: 1 };
map.set(key, 'value');
console.log(map.get(key)); // 'value'Pitfall 2: Memory Leaks with Map
// Wrong: Map holding references to removed DOM elements
const elementMap = new Map();
function process(el) {
elementMap.set(el, { processed: true });
el.remove(); // Element removed from DOM, but Map keeps it alive!
}
// Right: Use WeakMap
const elementWeakMap = new WeakMap();
function processSafe(el) {
elementWeakMap.set(el, { processed: true });
el.remove(); // Element will be garbage collected when no other refs exist
}Pitfall 3: Comparing Object References
const map = new Map();
map.set({ id: 1 }, 'Alice');
map.set({ id: 1 }, 'Bob');
console.log(map.size); // 2 — different object references!
// Solution: Use a string key or maintain a reference
const aliceKey = { id: 1 };
map.set(aliceKey, 'Alice');
console.log(map.get(aliceKey)); // 'Alice'Pitfall 4: WeakMap/WeakSet Limitations
const weakMap = new WeakMap();
weakMap.set({}, 'value');
// These do NOT work:
// weakMap.size // undefined
// weakMap.keys() // TypeError
// weakMap.forEach() // TypeError
// for (const x of weakMap) {} // TypeError
// Workaround: Use a regular Map when you need iteration
// Use WeakMap only when garbage collection of keys is essentialSerialization and Storage
Map and Set cannot be directly serialized to JSON. Here are reliable conversion patterns:
// Map serialization
const map = new Map([['a', 1], ['b', 2], ['c', 3]]);
// To JSON
const mapJSON = JSON.stringify(Object.fromEntries(map));
// '{"a":1,"b":2,"c":3}'
// From JSON (works for string keys only)
const restored = new Map(Object.entries(JSON.parse(mapJSON)));
// For non-string keys, use array format
const mapArray = JSON.stringify([...map]);
const restoredFromArr = new Map(JSON.parse(mapArray));
// Set serialization
const set = new Set([1, 2, 3, 4, 5]);
const setJSON = JSON.stringify([...set]);
const restoredSet = new Set(JSON.parse(setJSON));
// localStorage integration
function saveMap(key, map) {
localStorage.setItem(key, JSON.stringify([...map]));
}
function loadMap(key) {
const data = localStorage.getItem(key);
return data ? new Map(JSON.parse(data)) : new Map();
}Testing Strategies
describe('Map and Set collections', () => {
describe('Map', () => {
it('should store and retrieve key-value pairs with any key type', () => {
const map = new Map();
const objKey = { id: 1 };
map.set(42, 'number');
map.set(objKey, 'object');
map.set(true, 'boolean');
expect(map.get(42)).toBe('number');
expect(map.get(objKey)).toBe('object');
expect(map.get(true)).toBe('boolean');
expect(map.size).toBe(3);
});
it('should maintain insertion order', () => {
const map = new Map();
map.set('z', 1);
map.set('a', 2);
map.set('m', 3);
expect([...map.keys()]).toEqual(['z', 'a', 'm']);
});
it('should handle NaN as key', () => {
const map = new Map();
map.set(NaN, 'not a number');
expect(map.get(NaN)).toBe('not a number');
});
});
describe('Set', () => {
it('should deduplicate values', () => {
const set = new Set([1, 2, 2, 3, 3, 3]);
expect([...set]).toEqual([1, 2, 3]);
});
it('should perform set operations', () => {
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
const union = new Set([...a, ...b]);
const intersection = new Set([...a].filter(x => b.has(x)));
const difference = new Set([...a].filter(x => !b.has(x)));
expect([...union]).toEqual([1, 2, 3, 4]);
expect([...intersection]).toEqual([2, 3]);
expect([...difference]).toEqual([1]);
});
});
describe('WeakMap', () => {
it('should allow garbage collection of keys', () => {
let obj = { data: 'test' };
const weakMap = new WeakMap();
weakMap.set(obj, 'metadata');
expect(weakMap.get(obj)).toBe('metadata');
obj = null; // Object can now be garbage collected
// No way to verify GC in a test, but this demonstrates the pattern
});
});
describe('WeakSet', () => {
it('should track object membership without preventing GC', () => {
const visited = new WeakSet();
const node1 = { id: 1 };
const node2 = { id: 2 };
visited.add(node1);
expect(visited.has(node1)).toBe(true);
expect(visited.has(node2)).toBe(false);
});
});
});When to Use Each Collection
| Scenario | Best Choice | Why |
|---|---|---|
| Key-value pairs with string keys, static | Object | Simpler syntax, JSON-serializable |
| Key-value pairs with non-string keys | Map | No key coercion, any type supported |
| Frequent add/delete operations | Map | Optimized for mutations |
| Need insertion-order guarantee | Map | Reliable iteration order |
| Unique values / deduplication | Set | O(1) has, automatic dedup |
| Set operations (union, intersection) | Set | Clean API (ES2025+ built-in methods) |
| Attach metadata to objects | WeakMap | Prevents memory leaks |
| Track visited objects in traversal | WeakSet | Prevents memory leaks |
| Need to iterate over entries | Map or Set | WeakMap/WeakSet are not iterable |
Need .size property | Map or Set | WeakMap/WeakSet have no size |
| Serializable to JSON | Object or Map (via conversion) | Weak variants cannot be serialized |
Conclusion
JavaScript's collection types provide powerful alternatives to plain objects and arrays. Each collection has a specific niche:
- Map — The go-to for key-value pairs when keys are non-string types, when you need guaranteed iteration order, or when the collection undergoes frequent mutations.
- Set — Essential for uniqueness guarantees, membership testing, and set-theoretic operations on collections of values.
- WeakMap — The correct choice for associating metadata with objects without preventing garbage collection. Use it for private data, DOM metadata, and caching.
- WeakSet — Ideal for tracking object membership (visited nodes, processed items) without memory leak risks.
Key takeaways:
- Map supports any key type and maintains insertion order — use it over objects for dynamic collections
- Set provides O(1) membership testing and automatic deduplication — use it over
Array.includes() - WeakMap and WeakSet prevent memory leaks for object references — essential in long-running applications
- Choose based on key types, iteration needs, serialization requirements, and memory concerns
- V8 optimizes Map/Set internally with tiered hash table strategies for best performance
Explore collections further in the MDN documentation and practice with interactive examples to build intuition for when each collection shines.