The module cache: identity, singletons, and duplicate copies
A Node module is a singleton keyed by its resolved absolute path: the first require evaluates it once and every later require returns the same object. Two install locations of one package resolve to two keys — two instances — so instanceof and shared state silently break.
You ship a feature flag client as a module that exports one shared EventEmitter — every part of the app subscribes to flags.on("change"), and for months it just works. Then you add a plugin that lives in its own package and pulls the flag client through a transitive dependency, and suddenly half your app never sees flag changes. No error, no crash. You add logging and discover there are two emitters: the app’s listeners are on one instance, the plugin’s emit fires on another. Nothing in your code created a second one — npm did, by installing the flag client at two paths. Your “singleton” was a singleton per resolved path, and you had two paths.
The cache is keyed by resolved absolute path
When you call require("./config"), Node first resolves the specifier to a single absolute path — say /srv/app/config.js — and uses that path as a key into require.cache. The first time, there’s a cache miss: Node reads the file, wraps it in a function, runs it once, and stores the resulting module object (whose exports is what you get) under that key. Every later require() that resolves to the same path is a cache hit: Node skips evaluation entirely and hands back the same object reference. A cold evaluation parses and runs the file (milliseconds for a real module that opens a pool or reads config); a hit is a map lookup — microseconds, often a 100–1000× difference.
// config.js — evaluated exactly once
console.log("config evaluating"); // prints on the FIRST require only
module.exports = { db: "postgres://...", loadedAt: Date.now() };
// app.js
const a = require("./config");
const b = require("./config");
console.log(a === b); // true — same object reference
console.log(a.loadedAt === b.loadedAt); // true — body never re-ran
// require.cache is keyed by the resolved absolute path
console.log(Object.keys(require.cache)); // [ '/srv/app/app.js', '/srv/app/config.js' ]
const id = require.resolve("./config"); // '/srv/app/config.js'
console.log(require.cache[id].exports === a); // trueThis single mechanism is why a Node module behaves as a singleton. If you put a DB pool, a config object, or an EventEmitter bus at module scope and export it, the cache guarantees every importer shares the one instance — there is exactly one module.exports object per resolved path, created on first load and reused forever. You didn’t write a singleton pattern; the module cache is the singleton pattern. The instance identity that this buys you is the entire reason require("./pool") from forty files all share one connection pool instead of opening forty.
ESM caches too — but you cannot un-import
ES modules get the same once-only guarantee through a different structure: the module map, keyed by the resolved module URL (file:///srv/app/config.mjs). An ES module’s body is evaluated exactly once on first import, and every later import of that URL returns the same module namespace. The crucial difference for a senior is the escape hatch that doesn’t exist: there is no supported way to invalidate the ESM cache. CommonJS lets you reach into require.cache and delete an entry; the ESM module map has no public API to evict a URL. You cannot “un-import” a module.
// The only way to get ESM to re-evaluate is to change the KEY — the URL.
// A dev-only trick uses a cache-busting query string so the URL differs:
const fresh = await import(`./plugin.mjs?t=${Date.now()}`); // NEW key → re-evaluatesThis works because ./plugin.mjs?t=1 and ./plugin.mjs?t=2 are different URLs, so each is a fresh module-map entry. But it is a memory leak, not a reload mechanism: every distinct query string adds a permanent entry to the module map (modules are never garbage-collected from it), so a watcher that re-imports on every file change accumulates thousands of orphaned module instances and steadily climbs RSS. It is acceptable in a throwaway dev loop; it is never a production hot-reload. Production reload means a new process, behind a supervisor, not a growing module map.
▸Why this works
Why is identity tied to the path and not the package name? Because resolution produces a path, and only a path is unambiguous. require("lodash") from /srv/app might resolve to /srv/app/node_modules/lodash/index.js, while the same call from inside /srv/app/node_modules/some-plugin may resolve to /srv/app/node_modules/some-plugin/node_modules/lodash/index.js — a nested copy installed because of a version conflict. Same name, two paths, two cache keys, two completely independent module instances. The cache has no concept of “the lodash package”; it only knows file paths, so package identity and module identity are not the same thing the moment npm puts a package at more than one location.
The failure mode: two copies that don’t know about each other
This is where the cache turns into a production incident. When two versions — or two install locations of even the same version — of a package resolve to different paths, you get two separate module instances, each with its own module.exports, its own classes, its own module-scoped state. The consequences are subtle because nothing throws:
instanceofsilently returnsfalseacross the boundary. Copy A’sclass Tokenand copy B’sclass Tokenare different function objects with different prototypes, so aTokenmade by A failsvalue instanceof Tokenchecked against B’s class. Your validation just rejects valid objects, or accepts invalid ones, with no error.- Two event buses. As in the hook: copy A’s
EventEmitterand copy B’s never see each other’semit. Subscribers on one are deaf to the other. - Two “singletons.” Two config objects, two registries, two connection pools — you set a value on one and read
undefinedfrom the other. - React’s “Invalid hook call.” React keeps its dispatcher in module-scoped state; two copies of React means your component renders against one copy while hooks resolve against the other, and React throws Hooks can only be called inside a component / you might have more than one copy of React.
- Split
Symbolregistries. A library that uses a module-privateSymbolas a brand can’t recognize objects branded by its other copy.
// node_modules/lib/index.js (copy A) and a nested copy B at
// node_modules/plugin/node_modules/lib/index.js — SAME source, two paths
class Token {}
module.exports = { Token, make: () => new Token() };
// app pulls copy A; plugin pulls copy B
const a = require("lib"); // resolves to copy A's path
const t = require("plugin").makeToken(); // built by copy B's Token
console.log(t instanceof a.Token); // false — different class objects, no errorDiagnosis is mechanical once you know the cause: npm ls <pkg> prints the dependency tree and reveals when a package appears at two versions/locations; require.resolve("pkg") from two call sites shows two different absolute paths — the smoking gun. The fixes target deduplication: npm dedupe flattens compatible copies into one, overrides (npm/pnpm) or resolutions (Yarn) force a single version, declaring the shared lib as a peerDependency (so the host provides the one copy instead of each package bundling its own) is the standard fix for plugin ecosystems, and bundlers offer explicit dedupe/alias to collapse copies in a build. The numbers matter: two copies of a 300 KB library don’t just risk correctness — they roughly double that library’s footprint in the bundle and in resident memory, because each path is a distinct module that loads and stays loaded.
A plugin in its own package needs the SAME shared EventEmitter bus that the host app exports, but npm is installing the bus library at two paths so the plugin gets a second instance. What is the durable fix?
The delete require.cache[id] hot-reload hack
Because CommonJS exposes require.cache as a writable object, the tempting trick is delete require.cache[require.resolve("./handler")] so the next require re-evaluates the file — a poor man’s hot reload. It is dangerous, and the reasons are all identity reasons. Deleting the cache entry does not change anything that already holds the old exports: a closure, a registered route, an event listener, or another module that captured const h = require("./handler") still points at the old object. So you now have the old code running for everything that loaded before the delete and the new code for everything after — two versions live at once, with instanceof mismatches between them. Every reload also leaks memory: the orphaned old module instance (and anything it referenced) stays reachable through those stale closures and is never collected, so a long-running watcher’s heap grows reload by reload. And it silently breaks the singleton guarantee — the whole reason you used a module — by minting a second module.exports while the first is still in use. Real hot reload (the ergonomic kind in dev servers) works by tearing down and recreating a new process or worker, never by mutating one process’s module cache in place.
A class created by one copy of a package fails `value instanceof Lib.Token` when checked against another copy's class — with no error thrown. Why?
A dev tool re-imports an ES module on every file save using `import('./m.js?t=' + Date.now())`. What is the long-running cost?
- 01Two parts of an app each get a 'singleton' EventEmitter from the same package, yet events emitted by one are invisible to the other. Walk from the symptom to the root cause and the fix.
- 02Why is `delete require.cache[id]` a dangerous way to hot-reload, and how does real reload differ?
A Node module is a singleton keyed by its resolved absolute path: the first require() evaluates the file once and caches the resulting module.exports under that path in require.cache, and every later require() of the same path is a cache hit that returns the same object reference in microseconds instead of re-running the body — which is precisely why a module-scoped pool, config, or EventEmitter is shared everywhere. ESM gives the same once-only guarantee via the module map keyed by resolved URL, but with no supported invalidation: you cannot un-import, and the dev-only ?t=… query trick re-evaluates only by minting a new URL key, leaking module-map entries that are never collected. The tradeoff of free singletons is coupling to path identity, and the failure mode is duplicate copies: when one package resolves to two install paths you get two instances, so instanceof silently returns false, two event buses never see each other, two “singleton” configs diverge, React throws Invalid hook call, and a 300 KB lib doubles in bundle and RSS — diagnosed with npm ls / require.resolve showing two paths and fixed by npm dedupe, overrides/resolutions, or peerDependency so the package resolves to one path. Finally, delete require.cache[id] is a dangerous reload hack because stale closures keep the old exports (two versions live at once, broken identity) and the orphaned instances leak; real reload recreates a process, it never mutates the cache in place.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.