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.

JavaScriptadvanced
18 min read

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

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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 up

Safe Caching Strategies

javascriptjavascript
// 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"); // Cached

Debugging Memory Leaks

javascriptjavascript
// 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 PatternRoot CauseWeakMap Solution
DOM reference retentionMap/object holds strong ref to removed elementWeakMap releases entry when element is GC'd
Observer accumulationSubscribers never unsubscribedWeakRef + FinalizationRegistry for auto-cleanup
Unbounded cache growthCache never evicts entriesWeakMap keys auto-evict on GC
Closure captureClosure retains reference to large objectWeakMap decouples data from object lifecycle
Private data retentionModule-scope Map keeps instances aliveWeakMap entries collected with instances
Event handler retentionHandlers stored in Map with element keysWeakMap allows handler cleanup on element GC
Rune AI

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
RunePowered by Rune AI

Frequently Asked Questions

Does using WeakMap guarantee no memory leaks?

WeakMap prevents leaks from the data you associate with objects, but it does not prevent all types of memory leaks. If the object key itself is still referenced somewhere (in a closure, a global variable, an event listener, or another data structure), it will not be garbage collected and the WeakMap entry persists. WeakMap prevents leaks specifically in the pattern where associated data keeps an object alive beyond its useful lifetime. You must still manage the object's own lifecycle correctly.

How does FinalizationRegistry complement WeakMap?

FinalizationRegistry (ES2021) receives a callback when a registered object is garbage collected. Use it alongside WeakMap when you need side effects during cleanup, such as closing file handles, releasing WebSocket connections, or removing entries from a regular Map. FinalizationRegistry callbacks are not guaranteed to run during program execution and may be delayed indefinitely. Design systems so they work correctly without the callback firing and treat it as an optimization. WeakMap handles passive cleanup (data removal); FinalizationRegistry handles active cleanup (side effects).

Can WeakMap cause performance issues?

WeakMap operations (get, set, has, delete) are O(1) amortized, similar to regular Map. The garbage collector does additional work to track weak references, but this overhead is negligible in practice. Modern engines (V8, SpiderMonkey, JavaScriptCore) implement WeakMap using ephemeron tables that integrate efficiently with the garbage collection cycle. The performance benefit of preventing memory leaks far outweighs any GC overhead. Avoid using WeakMap for high-frequency, short-lived objects where the allocation and GC churn dominates the execution time.

Should I always use WeakMap instead of Map?

No. Use WeakMap specifically when keys are objects whose lifecycle you do not control, when you want associated data to be garbage collected with the key, or when the number of entries can grow unboundedly. Use regular Map when keys are primitives, when you need iteration (forEach, keys, values, entries), when you need a size count, when you need to clear all entries, or when you intentionally want the Map to keep objects alive. In most applications, Map is the more common choice; WeakMap solves specific memory management problems.

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.