Preventing Memory Leaks with JS WeakMaps Guide
Learn proven techniques to prevent JavaScript memory leaks using WeakMap. Covers common leak patterns, DOM reference management, listener cleanup, private data with automatic disposal, cache eviction, observer pattern memory safety, and debugging strategies.
Memory leaks in JavaScript occur when objects that should be garbage collected remain in memory because something still references them. WeakMap provides a structural solution by holding references that do not prevent garbage collection.
For WeakMap fundamentals, see JavaScript WeakMap and WeakSet Complete Guide.
Common Memory Leak Patterns
// LEAK PATTERN 1: Storing DOM references in a regular Map
// The Map keeps references alive even after DOM elements are removed
class LeakyEventTracker {
constructor() {
this.handlers = new Map(); // LEAK: strong references
}
track(element, handler) {
this.handlers.set(element, handler);
element.addEventListener("click", handler);
}
// Even if we call untrack, we might forget to call it
untrack(element) {
const handler = this.handlers.get(element);
if (handler) {
element.removeEventListener("click", handler);
this.handlers.delete(element);
}
}
}
// If an element is removed from DOM but untrack() is not called,
// both the element AND handler remain in memory forever
// FIX: Use WeakMap for automatic cleanup
class SafeEventTracker {
constructor() {
this.handlers = new WeakMap(); // SAFE: weak references
}
track(element, handler) {
this.handlers.set(element, handler);
element.addEventListener("click", handler);
}
untrack(element) {
const handler = this.handlers.get(element);
if (handler) {
element.removeEventListener("click", handler);
this.handlers.delete(element);
}
}
// If untrack() is not called but element is removed from DOM,
// the WeakMap entry + handler will be GC'd when element is collected
}
// LEAK PATTERN 2: Closures capturing large objects
function createProcessor() {
const cache = new Map(); // LEAK: cache grows without bound
return function process(item) {
if (cache.has(item)) return cache.get(item);
// Process item
const result = { ...item, processed: true, timestamp: Date.now() };
cache.set(item, result);
return result;
};
}
// FIX: WeakMap cache with object keys
function createSafeProcessor() {
const cache = new WeakMap(); // SAFE: entries collected when keys are GC'd
return function process(item) {
if (cache.has(item)) return cache.get(item);
const result = { ...item, processed: true, timestamp: Date.now() };
cache.set(item, result);
return result;
};
}Private Data with Automatic Disposal
// Class instances with private data using WeakMap
// Data is automatically cleaned up when instance is GC'd
const _internals = new WeakMap();
class Connection {
constructor(url, options = {}) {
_internals.set(this, {
url,
socket: null,
reconnectTimer: null,
messageQueue: [],
listeners: new Map(),
retryCount: 0,
maxRetries: options.maxRetries || 5,
retryDelay: options.retryDelay || 1000
});
}
connect() {
const state = _internals.get(this);
// Simulated WebSocket connection
state.socket = { readyState: 1, url: state.url };
state.retryCount = 0;
// Flush queued messages
while (state.messageQueue.length > 0) {
const msg = state.messageQueue.shift();
this.#send(msg);
}
}
#send(message) {
const state = _internals.get(this);
if (state.socket && state.socket.readyState === 1) {
console.log(`Sending: ${JSON.stringify(message)}`);
return true;
}
return false;
}
send(message) {
const state = _internals.get(this);
if (!state.socket || state.socket.readyState !== 1) {
state.messageQueue.push(message);
return false;
}
return this.#send(message);
}
disconnect() {
const state = _internals.get(this);
if (state.reconnectTimer) {
clearTimeout(state.reconnectTimer);
}
state.socket = null;
state.messageQueue = [];
state.listeners.clear();
// No need to explicitly delete from WeakMap
// When the Connection instance is GC'd, all private data goes with it
}
}
const conn = new Connection("wss://api.example.com");
conn.connect();
conn.send({ type: "hello" });
// When conn goes out of scope, everything is cleaned up automatically
// No timers left running, no socket references hanging around
// PRIVATE DATA FOR FRAMEWORK COMPONENTS
const componentState = new WeakMap();
class Component {
constructor(props) {
componentState.set(this, {
props,
prevProps: null,
mounted: false,
subscriptions: [],
childComponents: new Set()
});
}
mount() {
const state = componentState.get(this);
state.mounted = true;
this.onMount();
}
unmount() {
const state = componentState.get(this);
// Cleanup subscriptions
for (const unsub of state.subscriptions) {
unsub();
}
state.subscriptions = [];
// Cleanup children
for (const child of state.childComponents) {
child.unmount();
}
state.childComponents.clear();
state.mounted = false;
// WeakMap entry cleaned up when component is GC'd
}
onMount() {}
subscribe(store, callback) {
const state = componentState.get(this);
const unsub = store.subscribe(callback);
state.subscriptions.push(unsub);
return unsub;
}
}Observer Pattern Without Leaks
// Traditional observer pattern leaks when observers are not unsubscribed
// WeakRef + FinalizationRegistry provide automatic cleanup
class SafeEventEmitter {
#listeners = new Map(); // event -> Set of WeakRef
#registry = new FinalizationRegistry(({ event, ref }) => {
const set = this.#listeners.get(event);
if (set) {
set.delete(ref);
if (set.size === 0) {
this.#listeners.delete(event);
}
}
});
on(event, listener) {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
// Wrap the listener object
const wrapper = { callback: listener };
const ref = new WeakRef(wrapper);
this.#listeners.get(event).add(ref);
// Register for cleanup when wrapper is GC'd
this.#registry.register(wrapper, { event, ref });
// Return wrapper so caller can hold a strong reference
return wrapper;
}
emit(event, ...args) {
const set = this.#listeners.get(event);
if (!set) return;
for (const ref of set) {
const wrapper = ref.deref();
if (wrapper) {
wrapper.callback(...args);
} else {
set.delete(ref); // Clean up dead reference
}
}
}
}
// WEAKMAP-BASED OBSERVER WITH SUBSCRIPTION TRACKING
const subscriptions = new WeakMap();
class Store {
#state;
#subscribers = new Set();
constructor(initialState) {
this.#state = initialState;
}
getState() {
return this.#state;
}
subscribe(component) {
this.#subscribers.add(component);
// Track which stores a component subscribes to
if (!subscriptions.has(component)) {
subscriptions.set(component, new Set());
}
subscriptions.get(component).add(this);
// Return unsubscribe function
return () => {
this.#subscribers.delete(component);
const compSubs = subscriptions.get(component);
if (compSubs) compSubs.delete(this);
};
}
setState(updater) {
this.#state = typeof updater === "function"
? updater(this.#state)
: updater;
for (const sub of this.#subscribers) {
sub.onStateChange(this.#state);
}
}
}
// When component is GC'd, its subscription tracking
// in the WeakMap is automatically cleaned upSafe Caching Strategies
// BOUNDED CACHE WITH WEAKMAP BACKING
class HybridCache {
#weakCache = new WeakMap(); // Weak references for GC eligibility
#strongRefs = new Map(); // Strong references for LRU tracking
#maxSize;
#accessOrder = [];
constructor(maxSize = 100) {
this.#maxSize = maxSize;
}
get(key) {
// Check strong cache first (recently accessed)
if (this.#strongRefs.has(key)) {
this.#promoteAccess(key);
return this.#strongRefs.get(key);
}
// Check weak cache (might still be in memory)
if (this.#weakCache.has(key)) {
const value = this.#weakCache.get(key);
this.#addToStrong(key, value);
return value;
}
return undefined;
}
set(key, value) {
this.#weakCache.set(key, value);
this.#addToStrong(key, value);
}
#addToStrong(key, value) {
if (this.#strongRefs.size >= this.#maxSize) {
this.#evictOldest();
}
this.#strongRefs.set(key, value);
this.#accessOrder.push(key);
}
#promoteAccess(key) {
const idx = this.#accessOrder.indexOf(key);
if (idx > -1) {
this.#accessOrder.splice(idx, 1);
}
this.#accessOrder.push(key);
}
#evictOldest() {
const oldest = this.#accessOrder.shift();
if (oldest) {
this.#strongRefs.delete(oldest);
// Keep in weak cache -- it might survive if referenced elsewhere
}
}
get size() {
return this.#strongRefs.size;
}
}
// MEMOIZATION WITH WEAKMAP (prevents object argument leaks)
function weakMemoize(fn) {
const cache = new WeakMap();
return function memoized(obj, ...args) {
if (typeof obj !== "object" || obj === null) {
throw new TypeError("First argument must be an object");
}
// Create a cache key from remaining args
const argsKey = JSON.stringify(args);
if (!cache.has(obj)) {
cache.set(obj, new Map());
}
const objCache = cache.get(obj);
if (objCache.has(argsKey)) {
return objCache.get(argsKey);
}
const result = fn.call(this, obj, ...args);
objCache.set(argsKey, result);
return result;
};
}
const expensiveTransform = weakMemoize((config, mode) => {
console.log("Computing...");
return { ...config, mode, transformed: true, computedAt: Date.now() };
});
const config = { theme: "dark", lang: "en" };
expensiveTransform(config, "production"); // Computing...
expensiveTransform(config, "production"); // CachedDebugging Memory Leaks
// DIAGNOSTIC WEAKMAP WRAPPER
// Use in development to track WeakMap usage without preventing GC
class DiagnosticWeakMap {
#inner = new WeakMap();
#setCount = 0;
#getCount = 0;
#hitCount = 0;
#missCount = 0;
#label;
constructor(label = "anonymous") {
this.#label = label;
}
set(key, value) {
this.#setCount++;
this.#inner.set(key, value);
return this;
}
get(key) {
this.#getCount++;
if (this.#inner.has(key)) {
this.#hitCount++;
} else {
this.#missCount++;
}
return this.#inner.get(key);
}
has(key) {
return this.#inner.has(key);
}
delete(key) {
return this.#inner.delete(key);
}
stats() {
return {
label: this.#label,
sets: this.#setCount,
gets: this.#getCount,
hits: this.#hitCount,
misses: this.#missCount,
hitRate: this.#getCount > 0
? ((this.#hitCount / this.#getCount) * 100).toFixed(1) + "%"
: "N/A"
};
}
}
// Usage for debugging
const debugCache = new DiagnosticWeakMap("userDataCache");
const u1 = { id: 1 };
const u2 = { id: 2 };
debugCache.set(u1, { name: "Alice" });
debugCache.get(u1); // hit
debugCache.get(u2); // miss
debugCache.get(u1); // hit
console.table(debugCache.stats());
// { label: "userDataCache", sets: 1, gets: 3, hits: 2, misses: 1, hitRate: "66.7%" }
// MEMORY LEAK DETECTION PATTERN
// Track allocation counts to detect potential leaks
class LeakDetector {
#counts = new Map();
#thresholds = new Map();
register(category, threshold = 1000) {
this.#counts.set(category, 0);
this.#thresholds.set(category, threshold);
}
track(category) {
const current = (this.#counts.get(category) || 0) + 1;
this.#counts.set(category, current);
const threshold = this.#thresholds.get(category) || 1000;
if (current > threshold) {
console.warn(
`Potential memory leak: ${category} has ${current} entries ` +
`(threshold: ${threshold})`
);
}
}
untrack(category) {
const current = this.#counts.get(category) || 0;
this.#counts.set(category, Math.max(0, current - 1));
}
report() {
const entries = [];
for (const [category, count] of this.#counts) {
entries.push({
category,
count,
threshold: this.#thresholds.get(category),
status: count > this.#thresholds.get(category) ? "WARNING" : "OK"
});
}
return entries;
}
}| Leak Pattern | Root Cause | WeakMap Solution |
|---|---|---|
| DOM reference retention | Map/object holds strong ref to removed element | WeakMap releases entry when element is GC'd |
| Observer accumulation | Subscribers never unsubscribed | WeakRef + FinalizationRegistry for auto-cleanup |
| Unbounded cache growth | Cache never evicts entries | WeakMap keys auto-evict on GC |
| Closure capture | Closure retains reference to large object | WeakMap decouples data from object lifecycle |
| Private data retention | Module-scope Map keeps instances alive | WeakMap entries collected with instances |
| Event handler retention | Handlers stored in Map with element keys | WeakMap allows handler cleanup on element GC |
Rune AI
Key Insights
- WeakMap prevents the most common JavaScript memory leak where associated data keeps objects alive beyond their useful lifetime: DOM elements, component instances, and cached objects all benefit from weak reference semantics
- Private data stored in module-scoped WeakMaps is automatically disposed when the instance is garbage collected: This eliminates the need for explicit cleanup methods in most cases
- Combine WeakMap with FinalizationRegistry when you need active cleanup side effects like closing connections or releasing resources: Design systems to work without the callback and treat it as an optimization
- Hybrid caching with WeakMap backing and bounded strong cache provides both performance and memory safety: Recently accessed items stay in strong cache while older items can be GC'd
- Use diagnostic wrappers in development to measure WeakMap cache hit rates and identify potential leak patterns: Track allocation counts with threshold warnings to catch growing maps early
Frequently Asked Questions
Does using WeakMap guarantee no memory leaks?
How does FinalizationRegistry complement WeakMap?
Can WeakMap cause performance issues?
Should I always use WeakMap instead of Map?
Conclusion
WeakMap prevents memory leaks by ensuring that associated data does not keep objects alive beyond their useful lifetime. Private data storage, DOM metadata, caching, and observer patterns all benefit from WeakMap's automatic cleanup semantics. For the core WeakMap/WeakSet API, revisit JavaScript WeakMap and WeakSet Complete Guide. To understand the reflection patterns that pair well with WeakMap metadata, see JavaScript Reflect API Advanced Architecture.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.