
The Case Against Global Maps: Why Your Metadata Store Is a Memory Leak in Disguise
Standard Map collections are often just slow-motion memory leaks—learn how to leverage WeakMap and FinalizationRegistry to let the Garbage Collector reclaim your metadata.
I spent three hours staring at a heap snapshot last Tuesday, watching a "simple" caching layer slowly devour two gigabytes of RAM. The culprit wasn't a runaway while loop or a recursive function; it was a perfectly standard JavaScript Map that simply didn't know how to let go.
We’ve all been there—trying to be organized by keeping metadata separate from our core logic. We tell ourselves that keeping a global registry of object states is "cleaner" than polluting our class instances with internal properties. But in the world of high-performance JavaScript, that global Map is often a silent killer.
The Invisible Tether
In JavaScript, memory management is usually something we ignore until it forces its way into our lives. The Garbage Collector (GC) is our invisible janitor, cleaning up objects when they are no longer "reachable." But here’s the rub: reachability is a binary state.
If you put an object into a standard Map as a key, you have just created a strong reference to that object. As long as that Map exists in a scope that is reachable, every single key inside it is also reachable. Even if every other part of your application has forgotten about that object, the Map is holding its hand, telling the GC, "Don't touch him, he’s with me."
Consider this common pattern for a metadata store:
// metadataStore.js
const metadata = new Map();
export function setMetadata(obj, data) {
metadata.set(obj, data);
}
export function getMetadata(obj) {
return metadata.get(obj);
}On the surface, this is elegant. You can attach data to any object—even objects you don't own, like DOM elements or library-returned instances—without modifying the original object.
But look closer at the lifecycle. If you’re building a Single Page Application (SPA) and you use this store to track state for components, what happens when a component is destroyed? The DOM node is gone. The component instance is no longer referenced by the UI framework. But because it's a key in your metadata Map, it stays in memory forever. And so does the data object associated with it.
You haven't built a metadata store; you've built a memory leak that grows linearly with the usage of your app.
Enter the WeakMap: The Ghostly Reference
The solution to this specific "zombie object" problem is the WeakMap. A WeakMap is a collection of key/value pairs in which the keys are weakly referenced.
"Weakly referenced" means that the WeakMap does not prevent the Garbage Collector from reclaiming the key object. If the only remaining reference to an object is the entry in your WeakMap, the GC is free to incinerate that object and remove the entry from the map entirely.
// Better metadataStore.js
const metadata = new WeakMap();
export function setMetadata(obj, data) {
// If 'obj' is garbage collected later, this entry disappears automatically.
metadata.set(obj, data);
}The Fundamental Trade-offs
You can't just find-and-replace Map with WeakMap and call it a day. The "Weak" part comes with significant architectural constraints:
1. Keys must be Objects (or Symbols): You cannot use a string, a number, or a boolean as a key in a WeakMap. This is because primitives are not garbage collected in the same way; they are essentially eternal values in the context of the engine.
2. No Iteration: This is the big one. You cannot call metadata.keys(), metadata.values(), or metadata.forEach(). You can't even check the .size.
3. Why the secrecy? If WeakMap allowed iteration, the contents of the map would depend on the state of the Garbage Collector. Since GC timing is non-deterministic (it happens whenever the engine feels like it), your code's behavior would become unpredictable. One second map.size might be 10, the next it might be 8, without you ever calling .delete().
When the Value is the Problem
WeakMap solves the problem of the Key staying alive. But sometimes, the Value contains resources that need manual cleanup—like file descriptors, active timers, or network sockets.
Imagine you’re building a database connection pooler. You use a WeakMap to associate user objects with their active database connections.
const connections = new WeakMap();
function trackConnection(user, connection) {
connections.set(user, connection);
}When the user object is GC’d, the entry in the WeakMap vanishes. Great! But the connection object might still have an open TCP socket. The WeakMap let the reference die, but it didn't give us a hook to "close" the connection before it disappeared into the void.
The FinalizationRegistry: The Death Watcher
Introduced in ES2020, FinalizationRegistry is the companion piece to WeakMap. It allows you to request a callback when an object is garbage collected.
This is the missing link for robust metadata management. You use WeakMap for the data and FinalizationRegistry for the side effects.
Here is how you would implement a self-cleaning resource manager:
class ConnectionManager {
#registry;
#connections = new WeakMap();
constructor() {
// The callback runs AFTER the object is reclaimed.
this.#registry = new FinalizationRegistry((heldValue) => {
console.log(`Cleaning up resources for: ${heldValue.id}`);
heldValue.socket.destroy();
});
}
register(user, socket) {
const metadata = { id: user.id, socket: socket };
// Associate the socket with the user
this.#connections.set(user, metadata);
// Tell the registry to watch the 'user' object.
// When 'user' dies, it passes 'metadata' to the callback.
this.#registry.register(user, metadata);
}
}A Crucial Warning on Timing
Do not use FinalizationRegistry for critical business logic. The spec makes no promises about *when* the callback will run. It might be immediately after GC, or it might be minutes later. It is a tool for resource hygiene, not for primary application flow.
A Practical Use Case: The "Private Property" Pattern
Before JavaScript had native private fields (#field), we often used WeakMap to simulate them. Even now, WeakMap is superior when you want to store data that is truly unreachable from the instance itself, perhaps for security or to avoid naming collisions in a library.
const internalState = new WeakMap();
class SecureProcessor {
constructor(secretKey) {
internalState.set(this, {
key: secretKey,
iterations: 0
});
}
process(data) {
const state = internalState.get(this);
state.iterations++;
// ... perform logic
return `Processed with ${state.key}`;
}
}If you used a regular Map here, every SecureProcessor instance would live in memory forever because internalState holds a reference to this. By using WeakMap, as soon as your instance of SecureProcessor goes out of scope, the secretKey and the iterations counter are marked for deletion as well.
The "Global Map" Trap in Plugin Architectures
I’ve seen this pattern destroy the performance of plugin-based systems (like VS Code extensions or custom CMS editors).
The host application provides an object to the plugin. The plugin wants to track its own state regarding that object, so it creates a global Map.
// Plugin code
const activeEditors = new Map();
export function onEditorOpen(editor) {
activeEditors.set(editor, { startTime: Date.now() });
}
export function onEditorClose(editor) {
// If the developer forgets to call this, we have a leak.
activeEditors.delete(editor);
}The flaw here is the assumption of symmetry. We assume onEditorClose will always be called. But what if the plugin crashes? What if the host application force-closes the editor?
By switching to WeakMap, the plugin developer creates a "fail-safe" architecture. They don't need to worry about the onEditorClose logic for memory management—the language itself handles the cleanup when the editor object is destroyed by the host.
Limitations: The "Primitive Key" Problem
One of the biggest frustrations with WeakMap is when your "unique identifier" for an object is a string (like a UUID from a database) rather than the object reference itself.
If you have a Map<string, Metadata>, you cannot easily turn this into a WeakMap. Strings are not eligible as keys.
If you find yourself in this position, you have to ask: *Who owns the lifecycle of this metadata?*
If you are mapping a string ID to an object, and you want that object to die when the ID is no longer in use, you're looking for a WeakRef.
WeakRef allows you to hold a weak reference to an object, while the key in your map remains a strong string.
class UserCache {
#cache = new Map();
set(id, user) {
// We store a WeakRef instead of the user object itself
this.#cache.set(id, new WeakRef(user));
}
get(id) {
const ref = this.#cache.get(id);
if (!ref) return null;
const user = ref.deref();
if (user) {
return user;
} else {
// The object was GC'd, clean up the entry
this.#cache.delete(id);
return null;
}
}
}This is the inverse of a WeakMap. Here, the *value* is weak, but the *key* is strong. You still have to manually clean up the string keys eventually, but you've prevented the massive user objects from bloating your heap.
Summary: Choosing Your Weapon
Choosing the right collection is about defining the ownership of your data.
* Use `Map` when: You need to iterate over the keys, you need to know the size, or your keys are primitives (strings/numbers). Most importantly, use it when the entries should live as long as the Map itself.
* Use `WeakMap` when: You are associating metadata with objects you don't own, or you want the data to automatically vanish when the object key is destroyed. This is the default choice for "Metadata Stores."
* Use `WeakMap` + `FinalizationRegistry` when: You need to perform manual cleanup (closing ports, clearing buffers) when an object is reclaimed.
* Use `Map` + `WeakRef` when: Your keys are strings, but your values are large objects that you don't want to keep alive just because they are in the cache.
The Opinionated Take: Stop Defaulting to Map
In modern JavaScript development, we tend to use Map as a "better object." We like the API, the .set() and .get(), and the fact that keys can be anything. But this flexibility comes with a hidden responsibility for memory management that most developers aren't prepared to handle.
If your key is an object, you should have a very good reason *not* to use a WeakMap. By defaulting to strong references, you are essentially telling the engine that you know better than the Garbage Collector. Most of the time, you don't.
Building a "clean" metadata store isn't just about the API design; it's about ensuring that your store doesn't become a graveyard for every object your application has ever touched. Switch to WeakMap, let the GC do its job, and stop chasing ghosts in your heap snapshots.


