WeakMap and WeakSet: Memory-Friendly Collections

If you've been using Maps and Sets in your JavaScript applications (if not, check out my previous tutorial), you might have come across their lesser-known cousins: WeakMap and WeakSet. These specialized collection types were also introduced in ES6, but they're often overlooked despite offering unique memory management benefits.

In this tutorial, I'll show you how WeakMaps and WeakSets work, what makes them "weak," and when you should use them instead of regular Maps and Sets. We'll explore practical use cases where these collections really shine.

What makes a collection "weak"?

The "weak" in WeakMap and WeakSet refers to how they handle references to objects. In a standard Map or Set, if you use an object as a key (for Maps) or as a value (for Sets), that collection maintains a strong reference to the object. This means that as long as the Map or Set exists, JavaScript's garbage collector can't remove those objects from memory - even if there are no other references to them in your code.

WeakMap and WeakSet, on the other hand, hold weak references to objects. This means that if the only remaining reference to an object is in a WeakMap or WeakSet, the garbage collector is free to remove it from memory.

This seemingly subtle difference has huge implications for memory management in certain scenarios, as we'll soon explore.

WeakMap: Garbage collector-friendly key-value pairs

A WeakMap works similarly to a Map, with these key differences:

  1. Keys must be objects (not primitives like strings or numbers)
  2. Keys are weakly referenced
  3. WeakMaps are not iterable (no loops or size property)
  4. WeakMaps can't be cleared all at once

Here's the basic syntax:

// Creating a WeakMap
const metadataMap = new WeakMap();

// Using objects as keys
const user1 = { id: 1, name: 'Alice' };
const user2 = { id: 2, name: 'Bob' };

// Setting values
metadataMap.set(user1, { lastLogin: '2024-01-15', visits: 10 });
metadataMap.set(user2, { lastLogin: '2024-02-20', visits: 5 });

// Getting values
console.log(metadataMap.get(user1)); // { lastLogin: '2024-01-15', visits: 10 }

// Checking if a key exists
console.log(metadataMap.has(user1)); // true

// Deleting an entry
metadataMap.delete(user1);
console.log(metadataMap.has(user1)); // false

The memory management advantage

The most important feature of WeakMaps is how they handle object references. Let's see it in action:

let user = { name: 'Ralph' };

// With a regular Map
const regularMap = new Map();
regularMap.set(user, 'metadata');

// With a WeakMap
const weakMap = new WeakMap();
weakMap.set(user, 'metadata');

// Now, let's remove our reference to the user object
user = null;

// In a regular Map, the user object is still in memory
console.log(regularMap.size); // 1 - object still referenced in the Map

// In a WeakMap, the entry will be automatically removed when GC runs
// No way to check WeakMap size, but the user object will be garbage collected

When we set user = null, the only remaining reference to our original object is in the collections. The regular Map will keep that object in memory indefinitely, but the WeakMap allows it to be garbage collected because it only holds a weak reference.

Why no iteration methods?

You might have noticed that WeakMaps don't have methods like keys(), values(), entries(), or even a size property. WeakMaps cannot be iterated with for...of loops either. This is directly related to the weak references feature.

Since object keys could be garbage collected at any time (and the garbage collector might run at unpredictable moments), it would be impossible to reliably iterate over a WeakMap or determine its size. If JavaScript allowed iteration of WeakMaps, you might start iterating, then halfway through, some keys could be garbage collected, leading to inconsistent results.

WeakSet: Collections of garbage-collectable objects

WeakSet is to Set what WeakMap is to Map. It holds a collection of unique objects, but with weak references to those objects. Here's the basic syntax:

// Creating a WeakSet
const seenObjects = new WeakSet();

// Adding objects
const obj1 = { id: 1 };
const obj2 = { id: 2 };

seenObjects.add(obj1);
seenObjects.add(obj2);
seenObjects.add(obj1); // Duplicate - will be ignored

// Checking if an object exists
console.log(seenObjects.has(obj1)); // true

// Deleting an object
seenObjects.delete(obj1);
console.log(seenObjects.has(obj1)); // false

Like WeakMap, WeakSet:

  • Only accepts objects as values (not primitives)
  • Holds weak references to those objects
  • Is not iterable (no loops or size property)
  • Doesn't have a clear() method

Practical use cases for WeakMap and WeakSet

These collections may seem limited at first, but they excel in specific scenarios:

1. Associating private data with DOM elements

WeakMaps are perfect for storing data that's associated with DOM elements, without causing memory leaks:

// Store private data associated with DOM elements
const elementData = new WeakMap();

document.querySelectorAll('.interactive-widget').forEach(element => {
  // Associate data with each element
  elementData.set(element, {
    clickCount: 0,
    initialState: element.innerHTML,
    createdAt: new Date()
  });
  
  // Add event listeners
  element.addEventListener('click', handleClick);
});

function handleClick(event) {
  const element = event.currentTarget;
  const data = elementData.get(element) || { clickCount: 0 };
  
  // Update the associated data
  data.clickCount++;
  elementData.set(element, data);
  
  console.log(`Element clicked ${data.clickCount} times`);
}

// The beauty here: when elements are removed from the DOM and 
// garbage collected, their associated data in the WeakMap will
// also be eligible for garbage collection

If we had used a regular Map here, we'd have a memory leak—even after the DOM elements were removed, their data would remain in the Map.

2. Caching computed results for objects

WeakMaps are excellent for memoization when the input is an object:

// Cache for expensive calculations
const calculationCache = new WeakMap();

function calculateExpensiveResult(obj) {
  // Check if we've already performed this calculation
  if (calculationCache.has(obj)) {
    console.log('Cache hit!');
    return calculationCache.get(obj);
  }
  
  console.log('Cache miss, calculating...');
  
  // Perform the expensive calculation
  const result = performComplexComputation(obj);
  
  // Cache the result
  calculationCache.set(obj, result);
  
  return result;
}

function performComplexComputation(obj) {
  // Simulate an expensive operation
  let result = 0;
  for (let i = 0; i < 1000000; i++) {
    result += (obj.value * i) % 777;
  }
  return result;
}

// Usage
const data1 = { value: 42 };
const data2 = { value: 24 };

console.log(calculateExpensiveResult(data1)); // Cache miss
console.log(calculateExpensiveResult(data1)); // Cache hit
console.log(calculateExpensiveResult(data2)); // Cache miss

// If data1 is later garbage collected, its cached result will also be freed

With a regular Map, all cached results would remain indefinitely, potentially growing the memory footprint unnecessarily.

3. Marking objects that have been processed

WeakSets are perfect for marking objects as "seen" or "processed" without preventing them from being garbage collected:

// Keep track of processed objects
const processedObjects = new WeakSet();

function processObject(obj) {
  // Skip if already processed
  if (processedObjects.has(obj)) {
    console.log('Already processed this object, skipping');
    return;
  }
  
  console.log('Processing object:', obj);
  // Do some heavy processing...
  
  // Mark as processed
  processedObjects.add(obj);
}

// Usage
const item1 = { id: 'a1', data: [1, 2, 3] };
const item2 = { id: 'a2', data: [4, 5, 6] };

processObject(item1); // Processing object: {id: 'a1', data: [1, 2, 3]}
processObject(item1); // Already processed this object, skipping
processObject(item2); // Processing object: {id: 'a2', data: [4, 5, 6]}

// When item1 and item2 are no longer referenced elsewhere,
// they'll be garbage collected along with their entries in the WeakSet

4. Implementing a non-leaking event listener registry

WeakMaps can help manage event listeners without causing memory leaks:

// Store listeners associated with objects
const listenerMap = new WeakMap();

function addListener(target, eventType, callback) {
  // Get existing listeners for this target, or create a new entry
  let listeners = listenerMap.get(target) || {};
  
  // Initialize the array for this event type if needed
  if (!listeners[eventType]) {
    listeners[eventType] = [];
  }
  
  // Add the callback
  listeners[eventType].push(callback);
  
  // Update the WeakMap
  listenerMap.set(target, listeners);
  
  // Actually attach the listener
  target.addEventListener(eventType, callback);
}

function removeAllListeners(target, eventType) {
  const listeners = listenerMap.get(target);
  
  if (listeners && listeners[eventType]) {
    // Remove each attached listener
    listeners[eventType].forEach(callback => {
      target.removeEventListener(eventType, callback);
    });
    
    // Clear the registry
    listeners[eventType] = [];
    listenerMap.set(target, listeners);
  }
}

// Usage
const button = document.querySelector('#my-button');

addListener(button, 'click', () => console.log('Clicked!'));
addListener(button, 'mouseover', () => console.log('Mouse over!'));

// Later, remove all click listeners
removeAllListeners(button, 'click');

// If the button is removed from the DOM, the listener registry
// entry in the WeakMap becomes eligible for garbage collection

5. Implementing a private class field

Private class fields aren't available right now (yet?), so WeakMaps are the only way to implement truly private data in classes:

// Private data storage for instances
const privateData = new WeakMap();

class User {
  constructor(name, age) {
    // Store private data
    privateData.set(this, {
      name,
      age,
      createdAt: new Date()
    });
  }
  
  getName() {
    return privateData.get(this).name;
  }
  
  getAge() {
    return privateData.get(this).age;
  }
  
  // Only the class methods can access the private data
  getCreationDate() {
    return privateData.get(this).createdAt;
  }
}

const user = new User('Leigh', 29);
console.log(user.getName()); // 'Leigh'
console.log(user.getCreationDate()); // Date object

// Can't access the data directly:
console.log(user.name); // undefined
console.log(privateData.get(user)); // { name: 'Leigh', age: 29, createdAt: Date }
// (But note that the WeakMap itself is still accessible if it's in scope)

When to use WeakMap and WeakSet

Use WeakMap or WeakSet when:

  1. Your keys/values are objects, not primitive values
  2. You want to associate metadata with objects without preventing garbage collection
  3. You need a cache that automatically cleans up entries for objects that are no longer used
  4. You want to mark objects as "processed" without keeping them in memory
  5. You're working with DOM elements and want to avoid memory leaks

Use regular Map or Set when:

  1. You need to store primitive values as keys (Map) or values (Set)
  2. You need to know the size of the collection
  3. You need to iterate over the collection (forEach, for...of)
  4. You need collection-wide operations like .clear()

Browser Support

WeakMap and WeakSet have excellent browser support, available in:

  • Chrome 36+
  • Firefox 34+
  • Safari 8+
  • Edge 12+
  • IE 11+ (with some limitations)

For older browsers, you'll need a polyfill like the one provided by core-js:

import 'core-js/features/weak-map';
import 'core-js/features/weak-set';

Potential pitfalls

  1. Primitive values aren't allowed: Attempting to use primitive values with WeakMap or WeakSet will throw an error:
const weakMap = new WeakMap();
weakMap.set("string", 42); // TypeError: Invalid value used as weak map key
weakMap.set(42, "value"); // TypeError: Invalid value used as weak map key

const weakSet = new WeakSet();
weakSet.add("string"); // TypeError: Invalid value used in weak set
  1. No size information: You can't determine how many items are in a WeakMap or WeakSet:
const weakMap = new WeakMap();
weakMap.set({}, 'value');
// There's no size property or method to count entries
// console.log(weakMap.size); // undefined
  1. No iteration: You can't loop through entries:
const weakMap = new WeakMap();
weakMap.set({a: 1}, 'value1');
weakMap.set({b: 2}, 'value2');

// These methods don't exist:
// weakMap.keys()
// weakMap.values()
// weakMap.entries()
// weakMap.forEach()

Conclusion

WeakMap and WeakSet are specialized tools that fill a specific need in JavaScript: managing collections of objects while allowing unused objects to be garbage collected. When used in the right scenarios—like associating metadata with DOM elements, caching computed results, or tracking object processing—they help prevent memory leaks and improve the efficiency of your applications.

While they're more limited than their regular Map and Set counterparts (no iteration, no size property), their memory management benefits make them invaluable in certain situations.

Next time you're working with collections of objects and worried about memory management, consider reaching for WeakMap or WeakSet—they might be exactly the tool you need.