The mental model for ES Modules’ live bindings versus CommonJS value copies
17 mins read

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.

  • 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 of module.exports; a later module.exports = newObj is invisible to anyone who already required the module.
  • Circular imports differ: CJS hands callers a partially-populated exports object; ESM throws ReferenceError if a binding is read before its module finishes evaluating (TDZ).
  • Node 22.12 enabled require() of synchronous ESM by default, but throws ERR_REQUIRE_ASYNC_MODULE for any module containing top-level await.
  • Assigning to an imported binding is a parse-time SyntaxError, not a runtime error — the read-only-ness is structural, not enforced by const.

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.

Topic diagram for The mental model for ES Modules' live bindings versus CommonJS value copies
Purpose-built diagram for this article — The mental model for ES Modules’ live bindings versus CommonJS value copies.

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
Terminal output for The mental model for ES Modules' live bindings versus CommonJS value copies
Captured output from running it locally.

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.

Official documentation for esm live bindings vs commonjs
Canonical reference.

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.

Comparison: ESM vs CJS Bindings
Options compared side-by-side — ESM vs CJS Bindings.

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.

ESM imports vs CommonJS require() across eight dimensions
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.

  1. 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 undefined and the bug will surface 200 lines later. Treat ESM’s loud failure as the better outcome.
  2. You are mocking a module in tests. Jest’s classic jest.mock mutates require.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.
  3. You are reassigning module.exports after 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.
  4. You are interoping a CJS module from ESM. Expect import x from 'pkg' to give you module.exports; expect named imports to come from enumerable own properties; expect __esModule-marked transpiled output to interop transparently. If a named import resolves to undefined, the CJS module probably defined the property after module.exports was assigned, and Node’s static analysis missed it.
  5. You are calling require() on ESM in Node 22+. Works for sync graphs; throws for top-level await. If you control the ESM module and need it requirable, push the awaited work into an exported init().
Radar chart: ESM vs CJS Bindings
Different lenses on ESM vs CJS Bindings.

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

Leave a Reply

Your email address will not be published. Required fields are marked *