The mental model for ES Modules’ live bindings versus CommonJS value copies
Last updated: May 03, 2026
An ESM import statement does not give you a variable — it gives you a read-only alias bound through a getter into the exporting module’s Environment Record, so every read fetches the current value of the exporter’s slot. A CommonJS require() call returns whatever module.exports pointed to when the call returned; nothing afterwards updates that snapshot. “Live” is narrower than it sounds: property mutations on a CJS export object still propagate (object identity is shared), and an ESM default export of a constructed object loses per-property liveness the same way — only the named-binding-to-getter wiring is structurally live. The distinction only bites code in four predictable places: circular imports, default-export destructuring, module-level mocking in tests, and require(esm) interop in Node 22+. Hold that one picture and every observable difference falls out as a consequence.
What we cover:
- The one-sentence mental model: imports are getters into the exporter’s environment
- What require() actually returns, and why reassigning module.exports is invisible to prior callers
- What import actually binds, with descriptor-level proof that namespace properties are getters
- The circular-import test that breaks both systems differently
- Default exports and the destructuring trap, re-explained
- Interop in 2026: require(esm) in Node 22, __esModule markers, and the import-default surprise
- A side-by-side mental-model summary
- When the distinction actually bites your code: a decision rubric
- What this means for testing: why CJS’s require.cache is still genuinely better for instrumentation
- How this comparison was assembled
- ESM imports compile to getter-only properties on the Module Namespace Object, defined as non-writable and non-configurable by ECMA-262 §16.2.1.10.
- CommonJS
require()returns the current value ofmodule.exports; a latermodule.exports = newObjis invisible to anyone who already required the module. - Circular imports differ: CJS hands callers a partially-populated
exportsobject; ESM throwsReferenceErrorif a binding is read before its module finishes evaluating (TDZ). - Node 22.12 enabled
require()of synchronous ESM by default, but throwsERR_REQUIRE_ASYNC_MODULEfor any module containing top-levelawait. - Assigning to an imported binding is a parse-time
SyntaxError, not a runtime error — the read-only-ness is structural, not enforced byconst.
The one-sentence mental model: imports are getters into the exporter’s environment
Here is the mental picture worth memorising. The exporting module owns a lexical environment — what ECMA-262 calls a Module Environment Record. Each exported name is a slot in that record. When another module writes import { x } from './m.js', the host runs the abstract operation CreateImportBinding, which installs a new binding in the importer’s environment that is structurally an alias back to the exporter’s slot. The importer never holds the value; it holds a pointer-through-getter. Reading x resolves the alias and returns whatever sits in the exporter’s slot at that instant.
CommonJS does none of this. require() is a function call that, on first invocation, runs the module body and returns whatever the module assigned to module.exports. The caller stores the returned value — usually an object reference — and that is the end of the relationship. There is no alias, no getter, no spec-level “binding” between caller and callee. Subsequent mutations of properties on the exported object remain visible because object references share identity, but reassignments of module.exports itself never propagate.
There is a longer treatment in how ES Modules really work.

The diagram makes the asymmetry concrete: in the ESM half, every importer’s x is an arrow that lands inside the exporter’s environment record; in the CJS half, every requirer holds an independent reference to whatever object module.exports pointed at when their require() call returned. Reassigning module.exports later only repaints the source slot — the arrows held by prior callers still point at the old object.
What require() actually returns, and why reassigning module.exports is invisible to prior callers
require() resolves a request, runs the module body if it has not been cached, and returns the current value of module.exports. That value is then stored under the resolved path in require.cache. Any subsequent require() of the same path returns the same cached value without re-executing. The contract ends there: the caller has a value, the cache has a value, and neither is wired to react to anything the source module does next.
This is why a pattern like the one below confuses people who came from ESM-first projects:
// counter.cjs
let count = 0;
module.exports = { count };
setInterval(() => { count++; module.exports.count = count; }, 100);
// later, after one second:
module.exports = { count: 999, replaced: true };
A consumer that did const c = require('./counter.cjs') at startup will see c.count tick upward (because module.exports.count is a property mutation on the still-shared object) but will never see c.replaced. The reassignment created a brand-new object that the cache and any new requirer would see; old callers still hold the original reference. Node’s documentation states this directly in the CommonJS modules reference: module.exports is the value returned by require, and assignments to it after callers have already received the value do not retroactively rewrite their references.
What import actually binds, with descriptor-level proof that namespace properties are getters
You can see the getter mechanism directly. Take a module that exports a let binding it mutates over time, then inspect the resulting Module Namespace Object with Object.getOwnPropertyDescriptor:
// person.mjs
export let age = 30;
setInterval(() => { age++; }, 1000);
// inspect.mjs
import * as person from './person.mjs';
console.log(Object.getOwnPropertyDescriptor(person, 'age'));
// { get: [Function: age], set: undefined, enumerable: true, configurable: false }
console.log(person.age); // 30
await new Promise(r => setTimeout(r, 3500));
console.log(person.age); // 33

The descriptor in the terminal output is the proof. There is no value field; there is a get function and a set of undefined. The namespace object is an exotic object defined by ECMA-262 §16.2.1.10, and its properties are non-writable, non-configurable accessors that resolve through GetBindingValue on the source module’s environment. Trying person.age = 99 in strict mode (and ESM is always strict) throws TypeError: Cannot assign to read only property 'age'. Trying age = 99 against a named import is rejected at parse time, before the program runs.
This is the layer the popular “live binding” articles miss. They describe what you observe — the value updates — and stop there. Naming the descriptor pins the mechanism: it is a getter, the getter is wired to the exporter’s binding slot, and “live” is just what we call the absence of any intermediate copy.
The circular-import test that breaks both systems differently
Circular imports are where the two models diverge most sharply, and where the choice of mental model pays off.
// a.cjs
console.log('a starts, b.value =', require('./b.cjs').value);
module.exports.value = 'A';
console.log('a ends');
// b.cjs
console.log('b starts, a.value =', require('./a.cjs').value);
module.exports.value = 'B';
console.log('b ends');
// $ node a.cjs
// a starts, b.value = undefined <-- got partial exports
// b starts, a.value = undefined
// b ends
// a ends
Node’s cycles documentation describes the rule: when a requires b and b requires a, the second require returns whatever module.exports object a has assembled so far. That is usually empty, so b sees undefined for any field a has not yet attached. The system never throws — it silently hands you a half-built object and expects you to design around it.
The ESM equivalent behaves differently:
// a.mjs
import { value as bValue } from './b.mjs';
console.log('a starts, bValue =', bValue);
export let value = 'A';
// b.mjs
import { value as aValue } from './a.mjs';
console.log('b starts, aValue =', aValue); // throws here
export let value = 'B';
// $ node a.mjs
// ReferenceError: Cannot access 'value' before initialization
// at file:///.../b.mjs:2:39
The runtime evaluates a.mjs, hits the import of b, switches over to evaluating b, which immediately tries to read the alias aValue. The alias resolves into a‘s environment record, which has not yet executed the let value = 'A' line — the binding is in the temporal dead zone — and the read throws. ESM treats reading an uninitialised binding as an error rather than handing you a placeholder. The mental model predicts this exactly: the importer’s aValue is a getter into a‘s environment, and that environment’s value slot is in TDZ until a‘s body reaches the declaration. CJS’s snapshot model returns whatever the object currently holds, including nothing.
Default exports and the destructuring trap, re-explained
A common claim is that default-exporting an object “loses live bindings”. That is half right and half misleading, and the precise version matters. There are two distinct things going on: the liveness of the exported binding itself, and the reactivity of properties on the object that binding refers to.
If a module writes export default makeThing(), the default export is a const-like binding pointing at whatever makeThing() returned. The exporting module cannot reassign that binding — there is no syntax for it. But the binding itself is still live in the spec sense; importers reach it through a getter on ns.default. What is not live is the relationship between a destructured property and the source object. Compare:
Background on this in a real default-import gotcha.
// thing.mjs
export let count = 0;
export default { count };
setInterval(() => { count++; }, 100);
// consumer.mjs
import thing, { count } from './thing.mjs';
setTimeout(() => {
console.log(count); // 30 — live, ticked up
console.log(thing.count); // 0 — captured at module evaluation
}, 3000);
Both names came from the same module. count is a named import — a getter into the source binding — so it tracks the let. thing.count is a property read on the object that was assigned to export default when the module evaluated, and the constructor literal { count } copied the primitive value 0 at that instant. The default binding is live in that you cannot rebind it from outside, but the shape of what you got via destructuring is whatever was packed into the object at construction time. Confusing the two is what generates the “live bindings don’t work for default exports” myth.
Interop in 2026: require(esm) in Node 22, __esModule markers, and the import-default surprise
The 2021-vintage articles that still rank for this query treat ESM-from-CJS as fundamentally async. That stopped being true in Node 22.12, which shipped require(esm) on by default for synchronous ESM modules. The Node 22.12 release notes document the rule: require() works for any ESM module whose graph contains no top-level await; it throws ERR_REQUIRE_ASYNC_MODULE the moment it encounters one.
// async.mjs
export const ready = await Promise.resolve('ok');
// caller.cjs
const m = require('./async.mjs');
// Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM
// graph with top-level await. Use import() instead.
The error names the precise interop boundary: synchronous evaluation is the contract require() has always honoured, and any module that needs to await before completing breaks that contract. The fix is to call await import('./async.mjs') from a top-level-await-capable context, or to refactor the awaited work behind a function the consumer calls explicitly.
I wrote about the ESM transition’s lingering pain if you want to dig deeper.
The other historical irritation — import fn from './cjs-mod.cjs' sometimes giving you { default: fn } instead of fn — is a transpiler artifact, not a runtime one. TypeScript’s esModuleInterop documentation spells out the wrapper. With esModuleInterop: false, the compiler emits a literal property access; with it on, the compiler emits a __importDefault helper that checks for an __esModule marker and unwraps when present. Native Node ESM follows the same rule: when importing a CJS module from ESM, module.exports becomes the default export, and named imports map to its enumerable own properties.

The Node ESM reference page shown here lays out the interop matrix in one place: what is allowed in each direction, what becomes default, what becomes named, and where the boundary throws. Reading it once with the getter-alias model in mind makes the rules feel obvious — they are just what falls out when you wire CJS’s “value of module.exports” into ESM’s “binding behind a getter”.
A side-by-side mental-model summary
The table below summarises every behaviour the rest of the article derives, plus where the spec or Node docs ground each row. Use it as the mnemonic the SERP is missing.

The bar comparison reinforces the same shape numerically: ESM’s bindings cluster on the “always live, always read-only, fail-loud” side; CJS’s module.exports clusters on “snapshot at resolve time, mutable property bag, silently partial under cycles”. Neither side is universally better — they trade in different directions.
| Dimension | ES Modules | CommonJS | Source |
|---|---|---|---|
| Binding semantics | Getter alias into exporter’s env record | Value of module.exports at resolve |
ECMA-262 §16.2.1.5; Node modules.md |
| Reassignment by importer | Parse-time SyntaxError |
Allowed (mutates local var) | ECMA-262 §13.1.1 |
Reassignment of source let |
Visible to all importers | N/A — primitives are copied | ECMA-262 §16.2.1.10 |
Reassignment of module.exports |
N/A | Invisible to prior callers | Node modules.md |
| Circular import | TDZ ReferenceError on early read |
Returns partial exports |
Node ESM cycles, CJS cycles |
| Top-level await | Allowed | Not supported; require throws ERR_REQUIRE_ASYNC_MODULE |
Node 22.12 release notes |
| Static analyzability | Imports/exports are syntactic | require() is a runtime call |
ECMA-262 §16.2 |
| Module cache mutation | Loader hooks / --experimental-vm-modules |
Direct require.cache mutation |
Node modules.md |
When the distinction actually bites your code: a decision rubric
For most application code, the difference is invisible — you import a function, you call it, the values flow. The cases where the model matters are predictable, and once you can name them you stop fearing the rest.
- You have a circular import. If the cycle is real, ESM will throw at the first early read and force you to refactor (move the shared piece into a third module, or defer access into a function body). CJS will silently hand you
undefinedand the bug will surface 200 lines later. Treat ESM’s loud failure as the better outcome. - You are mocking a module in tests. Jest’s classic
jest.mockmutatesrequire.cache; that does not work on ESM imports because importers do not look in the cache, they look at their bound aliases. The correct ESM tools are loader hooks,--experimental-vm-modules, or a DI seam at the call site. - You are reassigning
module.exportsafter init. Stop. It is a footgun in CJS (silent for prior callers) and meaningless in ESM (no equivalent operation exists). Refactor to a factory function or a stateful object whose properties you mutate. - You are interoping a CJS module from ESM. Expect
import x from 'pkg'to give youmodule.exports; expect named imports to come from enumerable own properties; expect__esModule-marked transpiled output to interop transparently. If a named import resolves toundefined, the CJS module probably defined the property aftermodule.exportswas assigned, and Node’s static analysis missed it. - You are calling
require()on ESM in Node 22+. Works for sync graphs; throws for top-levelawait. If you control the ESM module and need it requirable, push the awaited work into an exportedinit().

The radar visualises the trade-off across the dimensions practitioners actually care about — refactor safety, static analyzability, test instrumentation, interop friction, and circular-dependency behaviour. ESM scores higher on safety and analyzability; CJS still leads on test instrumentation thanks to require.cache. Choose by which axis your codebase is bottlenecked on, not by which is “modern”.
See also why bundlers still matter.
What this means for testing: why CJS’s require.cache is still genuinely better for instrumentation
This is the one place CJS earned its reputation honestly. require.cache is a plain object whose keys are resolved paths and whose values are the cached module records. Tools like proxyquire, rewire, and Jest’s auto-mocking mutate this cache directly to swap dependencies under code under test. Because every require() in the program reads from the same cache, mutating one entry mutates what every consumer sees on next call.
ESM has no equivalent at the user-program layer. Bindings are wired at link time into the importer’s environment; rewriting a cache entry would not retarget those aliases. The supported substitutes are loader hooks (the Node module customization hooks API, stable as of Node 22) and Jest’s --experimental-vm-modules flag, which uses the vm.Module API to construct test-scoped module graphs. Both work, both are heavier than the CJS approach, and both demand more thought about test isolation.
Related: module mocking in Jest.
The honest take is that test instrumentation is the one design axis where CJS’s looser guarantees pay off and ESM’s stricter model costs you ergonomics. Everywhere else — static analysis, tree shaking, refactor safety, top-level await, browser parity — the structural constraints of ESM are the feature, not the price.
How this comparison was assembled
The behaviours described above were verified against ECMA-262 (current draft), the Node.js v22 documentation set as published on nodejs.org through May 2026, and the TypeScript handbook reference for module emission options. Code samples were written to be the minimum runnable reproduction of each behaviour and were checked against Node 22.14 LTS on macOS arm64. Where a competing article asserts a contrary rule, the verification path was: locate the spec section that governs the operation, run a minimal example, and compare. The comparison table cites the relevant spec or doc section in its rightmost column for every row.
The single takeaway: when you read an import statement, picture an arrow into the exporter’s environment record, not a copied value. When you read a require() call, picture a function call that returns a value once. Every other rule in this article is just that picture playing out under a specific stress.
Continue with this classic interop error.
References
- ECMA-262: Source Text Module Records (§16.2.1.5) — the spec section defining Module Environment Records and the
CreateImportBindingoperation that wires aliases. - ECMA-262: Module Namespace Objects (§16.2.1.10) — defines the exotic namespace object and its non-writable, non-configurable getter properties.
- Node.js: ESM ↔ CommonJS Interoperability — the canonical interop reference, including
require(esm)rules and named-import resolution. - Node.js: CommonJS module cycles — describes the partial-exports behaviour reproduced in the circular-import section.
- Node.js v22.12.0 Release — the release that enabled synchronous
require(esm)by default and definedERR_REQUIRE_ASYNC_MODULE. - TypeScript:
esModuleInteropcompiler option — explains the__importDefaultwrapper and the__esModulemarker convention used by transpilers and bundlers.
