Javascript Weakmap Weakref: The Memory Management Primitives Nobody Teaches

JavaScript WeakMap and WeakRef: The Memory Management Primitives Nobody Teaches
9 views

JavaScript has a garbage collector, so most developers never think about memory management. Then they build a client-side cache, or a component that attaches data to DOM nodes, or a pub/sub system — and slowly watch memory climb with no obvious cause. WeakMap and WeakRef exist specifically for these situations. They’re the most powerful and least understood primitives in the language.

⚡ TL;DR: WeakMap associates data with objects without preventing garbage collection. WeakRef holds a reference to an object that the GC is still allowed to collect. Use WeakMap for object metadata, WeakRef for caches. Both are invisible to the GC’s reachability analysis.

The Problem: Regular Map Creates Memory Leaks

// ❌ Classic memory leak: caching data tied to DOM nodes
const cache = new Map();

function processElement(element) {
 if (!cache.has(element)) {
 cache.set(element, computeExpensiveData(element));
 }
 return cache.get(element);
}

// When element is removed from DOM:
document.body.removeChild(element);
// element is removed from DOM, but cache still holds a reference!
// GC cannot collect element — MEMORY LEAK
// This is why SPAs slowly consume gigabytes over a session

// ✅ Fix: WeakMap — element can be GC'd even while in cache
const cache = new WeakMap();

function processElement(element) {
 if (!cache.has(element)) {
 cache.set(element, computeExpensiveData(element));
 }
 return cache.get(element);
}
// When element is removed from DOM and has no other references,
// GC collects it AND automatically removes the WeakMap entry
// Zero memory leak. Zero cleanup code needed.

WeakMap Deep Dive — What It Can and Can’t Do

const wm = new WeakMap();

// Keys MUST be objects (not primitives)
wm.set({}, 'value'); // ✅ object key
wm.set('string', 'x'); // ❌ TypeError: Invalid value used as weak map key

// WeakMap is NOT iterable (by design)
// You cannot enumerate keys/values — the GC controls key existence
for (const [k, v] of wm) {} // ❌ TypeError: WeakMap is not iterable
wm.size; // undefined — size is unknowable (GC may have collected keys)

// API: only has/get/set/delete
const key = { id: 1 };
wm.set(key, { computed: 'data', timestamp: Date.now() });
wm.has(key); // true
wm.get(key); // { computed: 'data', timestamp: ... }
wm.delete(key); // true

// Real use case: private class data (pre-#private syntax)
const _private = new WeakMap();
class BankAccount {
 constructor(balance) {
 _private.set(this, { balance, transactions: [] });
 }
 deposit(amount) {
 const data = _private.get(this);
 data.balance += amount;
 data.transactions.push({ type: 'deposit', amount });
 }
 get balance() { return _private.get(this).balance; }
}
// When account instance is GC'd, _private entry disappears automatically

WeakRef — A Nullable Pointer for JavaScript

// WeakRef holds a reference that doesn't prevent GC
// deref() returns the object OR undefined if it was collected

let expensiveObject = { data: new ArrayBuffer(1024 * 1024) }; // 1MB

const ref = new WeakRef(expensiveObject);

// Later, expensiveObject might be collected...
expensiveObject = null; // Remove the strong reference

// Try to use the object:
const obj = ref.deref();
if (obj) {
 console.log('Object still alive:', obj.data.byteLength);
} else {
 console.log('Object was garbage collected');
}
// You MUST check deref() result — it can be undefined at any time

// IMPORTANT: The GC can collect the object between any two deref() calls
// Never store deref() result across async operations:
const obj1 = ref.deref(); // alive
await someAsyncOperation();
const obj2 = ref.deref(); // might be undefined — obj1 and obj2 can differ!
// ✅ Correct: call deref() once, check result, use it synchronously

FinalizationRegistry — Run Code When Objects Are Collected

// FinalizationRegistry: callback when a WeakRef target is GC'd
const registry = new FinalizationRegistry((heldValue) => {
 console.log('Object collected, held value:', heldValue);
 // Clean up external resources (file handles, network connections, etc.)
});

let resource = { connection: createDbConnection() };
registry.register(resource, 'db-connection-cleanup');
// When resource is GC'd, callback runs with 'db-connection-cleanup'

// Practical use: LRU cache with automatic cleanup
class WeakCache {
 #cache = new Map();
 #registry = new FinalizationRegistry(key => this.#cache.delete(key));

 set(key, value) {
 const ref = new WeakRef(value);
 this.#cache.set(key, ref);
 this.#registry.register(value, key);
 }

 get(key) {
 const ref = this.#cache.get(key);
 if (!ref) return undefined;
 const value = ref.deref();
 if (!value) {
 this.#cache.delete(key); // Clean up stale entry proactively
 return undefined;
 }
 return value;
 }
}

const cache = new WeakCache();
let bigData = { results: new Array(100000).fill(0) };
cache.set('query-results', bigData);
bigData = null; // When collected, cache entry auto-removes

The Observer Pattern Without Leaks

// ❌ Classic event listener leak
class EventEmitter {
 #listeners = new Map();
 on(event, handler) {
 if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
 this.#listeners.get(event).add(handler);
 }
 // If subscriber is destroyed without calling off(), handler keeps it alive
}

// ✅ WeakRef-based observer — subscribers can be GC'd
class WeakEventEmitter {
 #listeners = new Map();

 on(event, handler) {
 if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
 this.#listeners.get(event).add(new WeakRef(handler));
 }

 emit(event, data) {
 const handlers = this.#listeners.get(event);
 if (!handlers) return;
 for (const ref of handlers) {
 const handler = ref.deref();
 if (handler) {
 handler(data);
 } else {
 handlers.delete(ref); // Auto-cleanup dead refs
 }
 }
 }
}

// Now subscribers don't need to explicitly unsubscribe
// When component is destroyed and handler function GC'd,
// next emit() call automatically cleans up the dead WeakRef

WeakMap vs WeakRef vs WeakSet Cheat Sheet

  • ✅ WeakMap — associate metadata with objects, object as key, auto-cleanup on GC
  • ✅ WeakRef — cache expensive objects, always check deref() result
  • ✅ WeakSet — track a set of objects without preventing their collection
  • ✅ FinalizationRegistry — run cleanup callbacks when objects are collected
  • ❌ Never store deref() result across await — object may be collected between calls
  • ❌ Never use WeakMap/WeakRef for data that must persist — use regular Map/strong references
  • ❌ Never rely on FinalizationRegistry for critical cleanup — GC timing is non-deterministic

These memory primitives become especially important when combined with the V8 JIT optimizer — WeakMap lookups are monomorphic and get JIT-compiled to near-native speed. For server-side memory management in Node.js, pair these techniques with the stream backpressure guide for complete memory control. External reference: MDN JavaScript Memory Management.

Disclosure: This post contains affiliate links. If you purchase through these links, CheatCoders earns a small commission at no extra cost to you. We only recommend tools and books we genuinely find valuable.

Related: Memory Management in JavaScript: Garbage Collection, Heap, and Leak Detection · TypeScript for JavaScript Developers: The Migration Guide That Actually Works · JavaScript Closures, Scope, and the Module Pattern: The Complete Guide

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

1 Comment

Leave a comment

No account needed. Name and email are optional.