WeakMap and WeakSet in JavaScript — Memory, Garbage Collection and Real-World Patterns
Memory leaks are one of the sneakiest bugs in long-running JavaScript applications. You build a dashboard that tracks user sessions, attach metadata to DOM nodes, or cache per-object configuration — and slowly, over hours, your app balloons in memory because old objects you thought were gone are still being held alive by a Map or Set somewhere. The JavaScript engine can't free them because something still has a reference. Users get sluggish UIs, Node.js servers restart themselves at 3 AM, and your on-call rotation hates you.
WeakMap and WeakSet exist precisely to solve this. They hold 'weak' references to their keys (WeakMap) or members (WeakSet). 'Weak' means: if the JavaScript runtime's garbage collector finds that the only remaining reference to an object is inside a WeakMap or WeakSet, that object is eligible for collection anyway. The weak collection doesn't count as 'keeping it alive'. The entry simply evaporates. No manual cleanup, no bookkeeping, no memory leaks from forgotten references.
By the end of this article you'll understand exactly how the garbage collector interacts with weak references, when you should reach for WeakMap or WeakSet over their regular counterparts, how to use them to build production-quality patterns like private class data, memoization caches, and DOM node metadata, and crucially — the non-obvious constraints that trip up even experienced developers.
How JavaScript's Garbage Collector Sees Weak References
Before touching a single line of WeakMap code, you need a clear mental model of reachability — the concept the JavaScript GC is built on.
The GC maintains an internal graph of every object in memory. It starts from a set of 'roots' (global variables, the call stack, closures that are still in scope) and follows every reference. Any object it can reach from a root is 'reachable' and stays alive. Anything unreachable gets collected.
A regular Map holds a strong reference to both keys and values. If you store an object as a key, that Map entry itself becomes a root-reachable path to the object. The object can never be collected as long as the Map is alive. This is the silent memory leak pattern most developers don't notice until it's too late.
A WeakMap holds a weak reference to its keys. The GC explicitly ignores weak references when it's computing reachability. So if the only path to an object runs through a WeakMap key, that object is considered unreachable. The GC can — and eventually will — collect it, and the WeakMap entry disappears atomically with it. Values in a WeakMap are still held strongly, but since you can't reach a value without its key, once the key is gone the value is freed too.
This 'invisible to the GC' quality is why WeakMap keys must be objects or registered symbols — primitives like strings and numbers are stored by value, have no identity, and the weak reference concept simply doesn't apply to them.
// Demonstrating that a WeakMap does NOT prevent garbage collection. // We use FinalizationRegistry (ES2021) to observe when an object is collected. // Note: GC timing is non-deterministic — this is illustrative, not a guarantee of exact timing. const collectionLog = new FinalizationRegistry((label) => { console.log(`[GC] Object labeled "${label}" has been garbage collected.`); }); // Create a WeakMap to store metadata about request objects const requestMetadata = new WeakMap(); // Simulate a request object arriving let incomingRequest = { id: 'req-001', path: '/api/users' }; // Attach metadata — timing info, headers, etc. requestMetadata.set(incomingRequest, { receivedAt: Date.now(), authenticated: true, rateLimitRemaining: 99, }); // Register with FinalizationRegistry so we know when it's collected collectionLog.register(incomingRequest, 'req-001'); console.log('Metadata while request is alive:'); console.log(requestMetadata.get(incomingRequest)); // { receivedAt: 1718000000000, authenticated: true, rateLimitRemaining: 99 } console.log('WeakMap has entry for request:', requestMetadata.has(incomingRequest)); // true // Simulate the request going out of scope — nulling the reference // After this line, no strong reference to the object remains. incomingRequest = null; // The GC is now FREE to collect the old request object. // The WeakMap entry will vanish when it does. // We can't force GC from user code, but the engine will do it during idle time. console.log('\nRequest reference nulled. The WeakMap entry will be freed by the GC automatically.'); console.log('No manual cleanup code needed — this is the entire point.'); // Compare with a regular Map — this WOULD leak memory: const leakyCache = new Map(); let anotherRequest = { id: 'req-002', path: '/api/posts' }; leakyCache.set(anotherRequest, { receivedAt: Date.now() }); anotherRequest = null; // The Map STILL holds a strong reference internally. // The object { id: 'req-002', ... } is NOT eligible for collection. // leakyCache.size === 1 forever, and that memory never returns. console.log('\nLeaky Map still has entry count:', leakyCache.size); // 1 — memory leak!
{ receivedAt: 1718000000000, authenticated: true, rateLimitRemaining: 99 }
WeakMap has entry for request: true
Request reference nulled. The WeakMap entry will be freed by the GC automatically.
No manual cleanup code needed — this is the entire point.
Leaky Map still has entry count: 1 — memory leak!
[GC] Object labeled "req-001" has been garbage collected. ← appears asynchronously
WeakMap Deep Dive — Private Data, DOM Metadata and Memoization
WeakMap's most powerful quality is that it creates a side-channel association between an object and some data, without modifying the object itself and without preventing the object from being collected. This unlocks three production patterns that are genuinely hard to implement cleanly any other way.
Pattern 1 — True Private Instance Data. Before private class fields (#field) were widely supported, WeakMap was the standard way to attach truly private data to class instances. Even today it's the only way to share private state across multiple cooperating classes without exposing it on the instance.
Pattern 2 — DOM Node Metadata. Frameworks and libraries frequently need to annotate DOM nodes with component state, event listener references, or render information. Putting this on the node directly (like jQuery did with $.data()) pollutes the object and creates leaks when nodes are removed. A WeakMap means the metadata lives exactly as long as the node does.
Pattern 3 — Bounded Memoization Cache. A regular object or Map used as a memoization cache grows forever unless you write eviction logic. A WeakMap-backed cache automatically evicts entries when the key objects are no longer used elsewhere — perfect for caching computed results keyed by objects like React component instances or database connection objects.
The constraint to always keep in mind: WeakMap is not iterable. You can't loop over its entries, get its size, or enumerate its keys. This is by design — if you could iterate it, you'd have a strong reference to every key, defeating the entire purpose.
// ───────────────────────────────────────────── // PATTERN 1: True private class data via WeakMap // ───────────────────────────────────────────── // The WeakMap lives in module scope — invisible outside this file const _bankAccountPrivate = new WeakMap(); class BankAccount { constructor(owner, initialBalance) { // Store sensitive data in the WeakMap, keyed by the instance itself // This data is NOT on `this` — you can't access it from outside _bankAccountPrivate.set(this, { balance: initialBalance, transactionHistory: [], pin: null, // set separately }); this.owner = owner; // public } deposit(amount) { const privateData = _bankAccountPrivate.get(this); privateData.balance += amount; privateData.transactionHistory.push({ type: 'deposit', amount, at: Date.now() }); console.log(`Deposited $${amount}. New balance: $${privateData.balance}`); } getBalance() { // Only the class itself can read from the WeakMap return _bankAccountPrivate.get(this).balance; } getTransactionCount() { return _bankAccountPrivate.get(this).transactionHistory.length; } } const aliceAccount = new BankAccount('Alice', 1000); aliceAccount.deposit(250); console.log('Balance:', aliceAccount.getBalance()); // 1250 console.log('Transactions:', aliceAccount.getTransactionCount()); // 2 // Attempts to read private data directly fail — it's NOT on the object console.log('Direct access attempt:', aliceAccount.balance); // undefined — protected! console.log('Object keys:', Object.keys(aliceAccount)); // ['owner'] — clean! // ───────────────────────────────────────────── // PATTERN 2: DOM node metadata without pollution // (Works in a browser; shown here conceptually for Node.js readers) // ───────────────────────────────────────────── const domNodeMetadata = new WeakMap(); function attachComponentData(domNode, componentState) { // Safe: when domNode is removed from the DOM and dereferenced, this entry // is automatically freed. No manual teardown, no memory leak. domNodeMetadata.set(domNode, { componentState, eventListeners: [], mountedAt: Date.now(), }); } function getComponentData(domNode) { return domNodeMetadata.get(domNode) ?? null; } // Simulate a DOM node (in Node.js context) let simulatedDomNode = { tagName: 'DIV', id: 'app-root' }; attachComponentData(simulatedDomNode, { count: 0, theme: 'dark' }); console.log('\nComponent data:', getComponentData(simulatedDomNode)); // { componentState: { count: 0, theme: 'dark' }, eventListeners: [], mountedAt: ... } // Simulate removing the node — memory freed automatically simulatedDomNode = null; // ───────────────────────────────────────────── // PATTERN 3: Self-evicting memoization cache // ───────────────────────────────────────────── const computationCache = new WeakMap(); function expensiveAnalysis(dataObject) { // Check cache first if (computationCache.has(dataObject)) { console.log('Cache hit — returning cached result'); return computationCache.get(dataObject); } // Simulate expensive work const result = { itemCount: dataObject.items.length, total: dataObject.items.reduce((sum, item) => sum + item.price, 0), average: 0, }; result.average = result.total / result.itemCount; // Cache the result — when dataObject is GC'd, this entry vanishes too computationCache.set(dataObject, result); console.log('Cache miss — computed and cached result'); return result; } let orderData = { id: 'order-789', items: [ { name: 'Widget', price: 29.99 }, { name: 'Gadget', price: 49.99 }, { name: 'Doohickey', price: 14.99 }, ], }; console.log('\nFirst call:'); console.log(expensiveAnalysis(orderData)); console.log('\nSecond call (same object):'); console.log(expensiveAnalysis(orderData)); // When orderData goes out of scope, the cache entry is freed automatically orderData = null;
Balance: 1250
Transactions: 2
Direct access attempt: undefined
Object keys: [ 'owner' ]
Component data: { componentState: { count: 0, theme: 'dark' }, eventListeners: [], mountedAt: 1718000000000 }
First call:
Cache miss — computed and cached result
{ itemCount: 3, total: 94.97, average: 31.656666666666666 }
Second call (same object):
Cache hit — returning cached result
{ itemCount: 3, total: 94.97, average: 31.656666666666666 }
WeakSet Deep Dive — Tracking Object Identity Without Retention
WeakSet is simpler than WeakMap but equally useful in specific scenarios. It stores a collection of objects with no duplicates (like a regular Set), but those objects are held weakly — again, they don't count toward reachability.
The core question WeakSet answers is: 'Have I seen this object before?' — without keeping that object alive just because you asked.
The most compelling production use case is marking objects as processed or visited in algorithms that deal with potentially large, dynamically created object graphs. Think of traversing a network of linked nodes, processing a stream of event objects, or tracking which DOM nodes have already had an event listener attached. With a regular Set, every object you've ever visited stays in memory. With a WeakSet, objects that are no longer referenced elsewhere are freed silently.
Another powerful pattern is guarding against double-initialization or re-entrant calls. You can use a WeakSet to track which objects have already been set up, and the guard automatically expires when the object does.
Like WeakMap, WeakSet is non-iterable and has no .size property. The available methods are just .add(), .has(), and .delete(). That constraint forces you to use it for what it's good at — membership testing — and nothing else.
// ───────────────────────────────────────────── // PATTERN 1: Cycle detection in object graph traversal // Without WeakSet, circular references cause infinite loops. // With a regular Set, every visited node is retained forever. // WeakSet gives us cycle detection with automatic memory release. // ───────────────────────────────────────────── function deepSerialize(rootObject) { const visitedNodes = new WeakSet(); // tracks objects we've already processed function serialize(currentValue) { // Primitives need no cycle check if (typeof currentValue !== 'object' || currentValue === null) { return currentValue; } // If we've been here before, we have a circular reference if (visitedNodes.has(currentValue)) { return '[Circular Reference Detected]'; } // Mark this object as visited BEFORE recursing into its properties visitedNodes.add(currentValue); if (Array.isArray(currentValue)) { return currentValue.map(serialize); } // Recursively serialize each property const serialized = {}; for (const [key, value] of Object.entries(currentValue)) { serialized[key] = serialize(value); } return serialized; } return serialize(rootObject); } // Build a deliberately circular structure const nodeA = { name: 'Alpha', children: [] }; const nodeB = { name: 'Beta', children: [] }; const nodeC = { name: 'Gamma', children: [] }; nodeA.children.push(nodeB); nodeB.children.push(nodeC); nodeC.children.push(nodeA); // circular! Gamma points back to Alpha console.log('Serialized circular graph:'); console.log(JSON.stringify(deepSerialize(nodeA), null, 2)); // ───────────────────────────────────────────── // PATTERN 2: One-time initialization guard // Prevents a setup function from running twice on the same object // ───────────────────────────────────────────── const initializedComponents = new WeakSet(); function initializeComponent(componentInstance) { if (initializedComponents.has(componentInstance)) { console.log(`\nComponent "${componentInstance.name}" is already initialized — skipping.`); return; } // Perform one-time setup componentInstance.isReady = true; componentInstance.createdAt = Date.now(); console.log(`\nComponent "${componentInstance.name}" initialized successfully.`); // Mark it so we never run this again for this instance initializedComponents.add(componentInstance); } const headerComponent = { name: 'HeaderNav', isReady: false }; const footerComponent = { name: 'FooterLinks', isReady: false }; initializeComponent(headerComponent); // runs initializeComponent(headerComponent); // blocked — already done initializeComponent(footerComponent); // runs console.log('\nHeader ready:', headerComponent.isReady); // true console.log('Footer ready:', footerComponent.isReady); // true // When a component is destroyed and the reference dropped, // the WeakSet entry vanishes — no stale entries accumulate over time. // ───────────────────────────────────────────── // PATTERN 3: Tracking which elements have listeners attached // Avoids accidentally adding duplicate event listeners to DOM nodes // ───────────────────────────────────────────── const listenersAttached = new WeakSet(); function safeAddClickListener(element, handler) { if (listenersAttached.has(element)) { console.log(`\nListener already attached to element #${element.id} — not adding again.`); return; } // In a real browser: element.addEventListener('click', handler); console.log(`\nAttaching click listener to element #${element.id}`); listenersAttached.add(element); } const buttonElement = { tagName: 'BUTTON', id: 'submit-btn' }; const clickHandler = () => console.log('Button clicked!'); safeAddClickListener(buttonElement, clickHandler); // attaches safeAddClickListener(buttonElement, clickHandler); // blocked — already attached safeAddClickListener(buttonElement, clickHandler); // blocked — already attached
{
"name": "Alpha",
"children": [
{
"name": "Beta",
"children": [
{
"name": "Gamma",
"children": [
"[Circular Reference Detected]"
]
}
]
}
]
}
Component "HeaderNav" initialized successfully.
Component "HeaderNav" is already initialized — skipping.
Component "FooterLinks" initialized successfully.
Header ready: true
Footer ready: true
Attaching click listener to element #submit-btn
Listener already attached to element #submit-btn — not adding again.
Listener already attached to element #submit-btn — not adding again.
WeakRef and FinalizationRegistry — The Full Picture (ES2021+)
WeakMap and WeakSet give you implicit weak references — you don't hold the reference directly, the collection does. ES2021 added WeakRef and FinalizationRegistry, which give you explicit weak references and cleanup callbacks. Understanding how they relate completes the picture.
WeakRef wraps an object and lets you hold a weak reference directly. You dereference it with .deref(), which returns either the object (if still alive) or undefined (if collected). It's designed for advanced caching and observer patterns where you want to observe an object without keeping it alive.
FinalizationRegistry lets you register a cleanup callback that fires after an object has been collected. This is useful for releasing external resources (C++ handles via WASM, file descriptors, etc.) that the JS GC doesn't know about.
The critical warning from the TC39 spec authors themselves: don't use WeakRef for ordinary application logic. GC timing is an implementation detail that varies across engines and environments. Code that works in V8 may behave differently in SpiderMonkey or JavaScriptCore. WeakRef and FinalizationRegistry are escape hatches for library authors and framework internals — not tools you should reach for in everyday application code. WeakMap and WeakSet are the right tool for 95% of use cases.
// Demonstrating WeakRef with a cache that gracefully handles collected entries // This is an advanced pattern — only use WeakRef when WeakMap won't work // (i.e., when you need to hold the weak reference directly, not as a Map key) class GracefulCache { constructor() { // Keys are strings (IDs), values are WeakRefs to the cached objects // This is impossible with WeakMap (which needs object keys) this._store = new Map(); // Clean up the Map entry after the object is collected this._registry = new FinalizationRegistry((cacheKey) => { const existingRef = this._store.get(cacheKey); // Only delete if the ref is actually dead (not replaced with a new one) if (existingRef && existingRef.deref() === undefined) { this._store.delete(cacheKey); console.log(`[FinalizationRegistry] Cache entry for key "${cacheKey}" cleaned up.`); } }); } set(key, value) { const weakRef = new WeakRef(value); this._store.set(key, weakRef); // Register the value with the cleanup callback, passing the key as the token this._registry.register(value, key, weakRef); console.log(`Cached object under key "${key}"`); } get(key) { const weakRef = this._store.get(key); if (!weakRef) return undefined; const cachedValue = weakRef.deref(); if (cachedValue === undefined) { // Object was collected — clean up synchronously on access too this._store.delete(key); console.log(`Cache miss (GC'd) for key "${key}"`); return undefined; } console.log(`Cache hit for key "${key}"`); return cachedValue; } get size() { return this._store.size; // Note: may include not-yet-cleaned dead entries } } const resourceCache = new GracefulCache(); let expensiveResource = { id: 'resource-001', data: new Array(1000).fill('payload'), // simulate a large object computedAt: Date.now(), }; resourceCache.set('resource-001', expensiveResource); console.log('\nImmediate retrieval:'); const retrieved = resourceCache.get('resource-001'); console.log('Retrieved id:', retrieved?.id); // resource-001 console.log('\nCache size before release:', resourceCache.size); // 1 // Release the strong reference expensiveResource = null; // In a real scenario, the GC would eventually run and the // FinalizationRegistry callback would fire, printing the cleanup message. // We can't force this deterministically — it's engine-controlled. console.log('\nStrong reference released.'); console.log('Cache size (GC may not have run yet):', resourceCache.size); console.log('WeakRef.deref() returns undefined after GC collects the object.'); // ───────────────────────────────────────────── // CONTRAST: When to use WeakMap vs WeakRef // ───────────────────────────────────────────── console.log('\n--- When to use which ---'); console.log('WeakMap: attach metadata TO an object (object is the key)'); console.log('WeakRef: hold a reference TO an object (string/number as key in outer Map)'); console.log('WeakSet: track WHETHER you have seen an object'); console.log('FinalizationRegistry: run cleanup AFTER an object is collected');
Immediate retrieval:
Cache hit for key "resource-001"
Retrieved id: resource-001
Cache size before release: 1
Strong reference released.
Cache size (GC may not have run yet): 1
WeakRef.deref() returns undefined after GC collects the object.
--- When to use which ---
WeakMap: attach metadata TO an object (object is the key)
WeakRef: hold a reference TO an object (string/number as key in outer Map)
WeakSet: track WHETHER you have seen an object
FinalizationRegistry: run cleanup AFTER an object is collected
[FinalizationRegistry] Cache entry for key "resource-001" cleaned up. ← fires asynchronously after GC
| Feature / Aspect | Map / Set (Strong) | WeakMap / WeakSet (Weak) |
|---|---|---|
| Key / member type | Any value — primitives, objects, symbols | Objects only (or registered Symbols in WeakMap) |
| Prevents GC of keys? | Yes — strong reference keeps objects alive | No — GC ignores weak references entirely |
| Iterable (forEach, for...of)? | Yes — full iteration support | No — intentionally non-iterable by spec design |
| Has .size property? | Yes — always accurate | No — would be unreliable due to concurrent GC |
| Has .clear() method? | Yes | No — entries expire automatically |
| Memory leak risk? | High if keys are objects you expect to be GC'd | None — entries vanish when keys are collected |
| Use case: object metadata? | Works, but causes memory leaks | Perfect — designed exactly for this |
| Use case: membership testing? | Works, but retains all members forever | Ideal when members should not be kept alive |
| Use case: string-keyed cache? | Yes — natural fit | No — strings are primitives, not valid weak keys |
| Introduced in spec? | ES6 (ES2015) | ES6 (ES2015) |
| JSON serializable? | No (neither is) | No (neither is) |
🎯 Key Takeaways
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.