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.

What lives at module scope A module environment box on the left represents a root for the whole session. It holds four bindings. CONFIG is fixed-size and fine. cache is an unbounded Map growing with every distinct key. registry is an array of component instances that are never removed. memo is an object keyed by every argument combination. The three accumulating bindings are marked as leak candidates. Module environment alive until page unload or process exit CONFIG — fixed size, meant to be permanent cache: Map — +1 entry per distinct key, no eviction registry: Array — instances pushed, never removed memo: Object — keyed by every argument combination

Step-by-Step Fix

  1. 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.
  2. Snapshot and sort by Retained Size. Take a heap snapshot at the end and sort the Summary view by Retained Size. Verification: a Map, Array or Object near the top has a retained size in the MB range.
  3. Trace to the module binding. Select it and read the Retainers pane. Verification: the path goes through system / Context and a function from a specific module file; clicking the function link opens the module in Sources where you find the top-level declaration.
  4. 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.
  5. 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.
  6. 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.
Unbounded versus bounded module cache over a session Heap troughs after each of twenty navigations. With an unbounded module-level cache, troughs climb from 48 to 210 megabytes. With a 200-entry LRU bound, troughs rise until the bound is reached around the fifth navigation, then stay flat near 70 megabytes. 210 MB 0 navigations 1 → 20 unbounded Map cache 200-entry LRU: flat after the bound

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.

Reading collection sizes across navigations Compare the size of each module-level collection in snapshots at navigation 10 and navigation 20. Equal sizes at the bound mean the fix holds. Growth proportional to navigation count means a teardown path is missing. Growth that stops at a bound far above what the UI shows means the bound is too generous. cache.size at navigation 20 vs navigation 10 Fixed: bounded collection is full and stays flat equal, at the bound A teardown path is still missing; find the registration without a removal grows with navigations Bound too generous; lower the cap or tie entries to views stops, but far above screen needs

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.