Module-Level Caches and Global Singleton Leaks
Memory climbs steadily over a long session, and every retainer path ends at a variable declared at the top of a module — a cache, a registry, a listeners array or a singleton service. This guide from Closure Memory Leaks in Modern JavaScript, part of Browser DevTools & Performance Profiling Workflows, explains why module scope behaves like a permanent root and how to design module-level state that cannot grow without limit.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Retainer path ends at system / Context of a module |
Top-level const cache = new Map() lives as long as the module |
Find the module variable; add a size limit or scope it | Growth stops at the limit instead of continuing all session |
| Registry of components/instances keeps unmounted ones | Registration without deregistration | Deregister on teardown, or key with WeakMap/WeakRef |
Unmounted instances become collectable |
| Singleton service accumulates per-user or per-route data | Long-lived object used as a scratchpad | Reset or partition state on logout/route change | Heap returns to baseline after logout |
| Memoisation helper grows with distinct inputs | Unbounded memo keyed by arguments | Bound the memo (LRU) or scope it to a component | Memo size capped at N entries |
| Node.js server memory grows with traffic | Module cache keyed by request data | Use TTL/LRU; never key by unbounded request values | RSS stable under sustained load |
Root Cause: Module Scope Is a GC Root for the Whole Session
An ES module is evaluated once, and its top-level bindings are stored in the module’s environment, which the module record keeps alive for as long as the module is loaded — in a browser page, until navigation; in Node.js, until the process exits. In a heap snapshot this environment appears as a system / Context retained by the module’s functions and ultimately by a root. Anything reachable from a top-level variable is therefore effectively permanent unless your code removes it.
That is exactly what you want for configuration and constants. It becomes a leak when a top-level structure accumulates: a Map used as a cache with no eviction, an array of registered instances with no deregistration, a memo object keyed by every distinct argument ever seen, an event-listener list on a shared bus. In a single-page app, each navigation or interaction adds entries, none are removed, and the heap grows linearly with session length. On a server the same pattern grows with traffic and ends in an out-of-memory crash, as covered in caching vs memory bloat in SSR data layers.
These leaks are easy to create because module-level caches look harmless in isolation and are genuinely useful. The code that fills the cache rarely knows when an entry becomes obsolete, and nobody owns eviction. They are also easy to diagnose once you know the shape: the retainer path of a leaked object runs through a named collection, then a module context, then a function defined in that module — and every one of the leaked objects shares that same path. Using what distance means in a heap snapshot helps here too: all entries in a module-level collection sit at the same small distance from the root.
The fix is to give every module-level collection an explicit policy: a maximum size, a time-to-live, an owner that clears it, or weak keys so entries disappear with their subjects.
Step-by-Step Fix
- Confirm linear growth with session length. Record the JS heap over a long scripted session — twenty navigations or a few hundred interactions — using DevTools → Performance with Memory ticked, or the Task Manager’s live JS figure. Verification: heap troughs rise steadily with each repetition rather than plateauing.
- Snapshot and sort by Retained Size. Take a heap snapshot at the end and sort the Summary view by Retained Size. Verification: a
Map,ArrayorObjectnear the top has a retained size in the MB range. - Trace to the module binding. Select it and read the Retainers pane. Verification: the path goes through
system / Contextand a function from a specific module file; clicking the function link opens the module in Sources where you find the top-level declaration. - Classify the binding. Decide whether it is a cache (values can be recomputed), a registry (tracks live instances), or scratch state (per-user or per-route data). Verification: you can state who should remove entries and when.
- Apply the matching policy. Caches get a size or time bound; registries get deregistration in teardown or become
WeakMap/WeakRef-based; scratch state gets reset on the event that ends its relevance (logout, route change). Verification: every insertion path has a corresponding removal path or bound. - Re-run the long session. Repeat the same scripted session. Verification: heap troughs plateau, and the collection’s size stays at or below its bound throughout.
Command and Code Reference
Use case: a module-level cache with a size bound. Map preserves insertion order, so re-inserting on access and deleting the first key gives a compact LRU.
// thumbnails.js — module scope, but bounded
const MAX = 200;
const cache = new Map(); // key → ImageBitmap or data URL
export function getThumb(url) {
if (cache.has(url)) {
const v = cache.get(url);
cache.delete(url); // move to most-recently-used position
cache.set(url, v);
return v;
}
const v = makeThumb(url);
cache.set(url, v);
if (cache.size > MAX) cache.delete(cache.keys().next().value); // evict LRU
return v;
}
// Give the owner a way to reset it (e.g. on logout)
export function clearThumbs() {
cache.clear();
}
Use case: a registry that must not keep unmounted instances. A WeakMap keyed by the element lets entries vanish with their subjects; an explicit unregister covers iteration needs.
// tooltip-registry.js
const byElement = new WeakMap(); // element → tooltip; no leak when element is dropped
const active = new Set(); // for iteration; must be cleaned explicitly
export function register(el, tooltip) {
byElement.set(el, tooltip);
active.add(tooltip);
return function unregister() { // call from the component's teardown
byElement.delete(el);
active.delete(tooltip);
};
}
export function hideAll() {
for (const t of active) t.hide();
}
Verification and Regression Prevention
After the change, the long-session recording should show heap troughs that level off once each bounded collection is full, and snapshots taken at navigation 10 and navigation 20 should show the collection at the same size. Log cache.size and active.size in development builds so you can watch them during manual testing; a registry that grows with navigation count means a teardown path is still missing.
For prevention, add a lint rule or review checklist that flags new Map(), new Set(), [] or {} assigned to a top-level let/const that is later mutated, and requires a comment stating its bound or owner. For caches with non-trivial eviction needs, reuse one well-tested implementation — see building a bounded LRU cache in JavaScript — rather than hand-rolling a new Map in every module.
Edge Cases and Gotchas
Hot module replacement duplicates module state
During development, hot module replacement re-evaluates changed modules, creating a fresh module environment each time. If the old instance registered listeners on window or on another module’s emitter, those registrations keep the old environment — and its caches — alive. Memory growth that only happens while editing code is usually this; use the bundler’s dispose hook to unregister, and do not profile leaks in a long HMR session.
Caching promises instead of values
Caching the promise returned by a fetch (cache.set(url, fetch(url).then(r => r.json()))) de-duplicates concurrent requests, which is good, but a rejected promise stays cached forever and every caller receives the same failure. Delete the entry in a catch handler, and apply the same size bound as for value caches.
Singletons in tests
Module-level state persists across tests in the same worker process unless the test runner isolates modules. A test suite whose memory grows with the number of tests is often accumulating registry entries from every test. Expose reset functions for module state and call them in afterEach, or enable module isolation in the runner.
Server modules shared across requests
In Node.js, module state is shared by every request the process serves. Anything keyed by a user, session or request ID in module scope grows with traffic and can also leak data between users. Keep per-request data in request-scoped structures and reserve module scope for bounded, user-independent caches.
Frequently Asked Questions
Are module-level variables garbage collected?
The module environment is kept alive for as long as the module is loaded, which in practice is the life of the page or process. Values assigned to top-level variables are therefore not collected unless you overwrite or delete them. Objects that are no longer referenced by any binding are collected normally.
Should I use WeakMap for every module cache?
Only when the key is an object whose lifetime should control the entry’s lifetime, such as a DOM element or a component instance. WeakMap cannot be keyed by strings or numbers and cannot be iterated or sized. Caches keyed by URLs, IDs or query strings need a size or time bound instead.
Why does this leak only show up in long sessions?
Each individual entry is small, so short sessions barely notice. The problem is that growth is linear in the number of distinct keys or registrations, which grows with session length or traffic. Scripted long sessions and soak tests expose it quickly, which is why they belong in any memory testing strategy.
Related
- Closure Memory Leaks in Modern JavaScript — the parent topic
- Memoization Without Unbounded Memory Growth — bounding memo caches specifically
- WeakMap vs WeakRef vs FinalizationRegistry: When to Use Each — choosing weak structures for registries
- Browser DevTools & Performance Profiling Workflows — the section overview