Lesson 03 / 14
CommonJS
The call-based loading model, the module wrapper and cache, the difference between a value copy and a live binding, the two systems' behavior on circular dependencies, and interoperability.
Contents
The previous lesson established a model where import is a statement and binding completes before evaluation. The language has a second module system that became widespread earlier, and it still carries a large share of the code that runs.
The two existing side by side is not a transitional accident, it is a permanent state: in a single project, files from both systems sit next to each other and call one another. This lesson’s question is what the second system does differently, and where the boundary between the two models sits.
Loading Is a Function Call
In this system, obtaining a module means calling a function named require; exporting
means writing to an object named module.exports. Neither is syntax — both are
ordinary runtime operations.
This means the source text is never run directly. The runtime wraps the file’s content in a function body and calls that function with five arguments:
// file: wrapper.cjs console.log(typeof require, typeof module, typeof exports); console.log(__filename.endsWith('wrapper.cjs'), typeof __dirname); console.log(module.exports === exports);
$ node wrapper.cjs function object object true string true
These five names are not part of the language; they are supplied by the module wrapper. Module scope here is the same isolation obtained at the parsing level in the previous lesson, obtained instead through an ordinary function scope. The result is identical: top-level declarations do not leak into the global scope.
The last line also shows a trap: exports starts out as the same object as
module.exports. Writing exports.name = ... affects both, but writing
exports = ... changes only the local name and does not touch the export surface. To
replace the surface entirely, module.exports has to be assigned.
A direct consequence of being call-based is that loading can be conditional and computed:
// file: format-plain.cjs module.exports = (measurement) => `word: ${measurement.wordCount}`;
// file: choice.cjs — format-plain.cjs is the file above const mode = process.argv[2] ?? 'plain'; const format = require(`./format-${mode}.cjs`); console.log(format({ wordCount: 13, sentenceCount: 2 }));
$ node choice.cjs word: 13
Since the specifier is computed at runtime, which file gets loaded cannot be known by reading the source. This is the price of that flexibility: tools that do static analysis cannot see this dependency, and tree shaking cannot be applied.
The Cache and Single Evaluation
When the same file is requested more than once, its body runs exactly once. The runtime keeps a cache keyed by the resolved file path:
// file: counter.cjs console.log('counter.cjs body ran'); let count = 0; module.exports = { increment() { count += 1; return count; }, get value() { return count; }, };
// file: cache.cjs — counter.cjs is the file above const a = require('./counter.cjs'); const b = require('./counter.cjs'); console.log('same object:', a === b); a.increment(); console.log('read through b:', b.value); console.log('registered in cache:', require.resolve('./counter.cjs') in require.cache);
$ node cache.cjs counter.cjs body ran same object: true read through b: 1 registered in cache: true
The body ran once, and the two calls gave the same object. The fourth of the four guarantees listed in the previous lesson — single evaluation — is realized here through an observable table. The cache’s key is the resolved absolute path; if the same file is reached through different paths, the entry differs too.
Copy and Live Binding
module.exports is an ordinary object; importing is an ordinary read. This means the
previous lesson’s live-binding behavior does not exist here:
// file: record.cjs let callCount = 0; function count() { callCount += 1; } module.exports = { callCount, count };
// file: copy.cjs — record.cjs is the file above const { callCount, count } = require('./record.cjs'); const whole = require('./record.cjs'); count(); count(); console.log('taken by destructuring:', callCount); console.log('through the object:', whole.callCount);
$ node copy.cjs taken by destructuring: 0 through the object: 0
Both reads gave zero. When the module.exports object was built, callCount’s value
at that moment was copied into the object; the increments that followed changed the
local variable but not the object. The same scenario gave 2 in standard module
syntax.
This difference is a source of silent bugs in code moved between the two systems. If a
changing counter or flag is being exported, the call-based system needs a getter
computed at read time — this is exactly what the get value definition in the
counter.cjs example was for.
Circular Dependency
When two modules import each other, the graph contains a cycle. No system can resolve this cycle; what they can do is break it at a specific point. Where they break it differs between the two systems.
In the call-based system, require finds a partially filled export object in the
cache and returns it:
// file: a.cjs console.log('a: body started'); exports.name = 'A'; const b = require('./b.cjs'); console.log('a: b.name =', b.name, '| b.define =', typeof b.define); exports.define = function () { return 'A description'; }; console.log('a: body finished');
// file: b.cjs console.log('b: body started'); const a = require('./a.cjs'); console.log('b: a.name =', a.name, '| a.define =', typeof a.define); exports.name = 'B'; exports.define = function () { return 'B description'; }; console.log('b: body finished');
$ node a.cjs a: body started b: body started b: a.name = A | a.define = undefined b: body finished a: b.name = B | b.define = function a: body finished (node:4526) Warning: Accessing non-existent property 'define' of module exports inside circular dependency (Use `node --trace-warnings ...` to show where the warning was created)
b.cjs saw a.cjs in a half-run state: name was defined, define was not. The
program did not crash; it gave a missing value. The process number in the warning line
changes on every run.
In standard module syntax, since binding completes before evaluation, the names already exist; the problem is that a name’s value has not been assigned yet. Function declarations are ready before the body runs, so they can be used without trouble:
// file: a.mjs import { bName, bDefine } from './b.mjs'; console.log('a: body started'); export const aName = 'A'; export function aDefine() { return 'A description'; } console.log('a: b.bName =', bName, '| bDefine() =', bDefine());
// file: b.mjs import { aName, aDefine } from './a.mjs'; console.log('b: body started'); console.log('b: aDefine() =', aDefine()); export const bName = 'B'; export function bDefine() { return 'B description'; }
$ node a.mjs b: body started b: aDefine() = A description a: body started a: b.bName = B | bDefine() = B description
b.mjs was able to call a function from a.mjs, even though a.mjs’s body had not
run yet. In the same mechanism, an early access to a value defined with const does
not give a silent undefined, it throws:
// file: a2.mjs import { bName } from './b2.mjs'; export const aName = 'A'; console.log('a2: bName =', bName);
// file: b2.mjs import { aName } from './a2.mjs'; console.log('b2: aName =', aName); export const bName = 'B';
$ node a2.mjs 2>&1 | grep '^ReferenceError' ReferenceError: Cannot access 'aName' before initialization
The distinction can be summed up: one lets missing data pass silently, the other stops the access. In both, the real fix is the same — a circular dependency is a design smell, and it is resolved by extracting the shared part into a third module.
Interoperability
When the two systems coexist in the same project, which file is read under which rule
is decided by a marker: the .mjs extension forces the standard module, .cjs forces
the call-based module; for files with a .js extension, the type field in the
nearest manifest is consulted.
Access from the standard module to the call-based module is direct:
// file: legacy-format.cjs function format(number) { return number.toFixed(2); } module.exports = { format, version: '1.0.0' };
// file: consumer.mjs — legacy-format.cjs is the file above import legacy from './legacy-format.cjs'; import { format } from './legacy-format.cjs'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const manual = require('./legacy-format.cjs'); console.log('default export:', legacy); console.log('named import:', format(3.14159)); console.log('with createRequire:', manual === legacy);
$ node consumer.mjs
default export: { format: [Function: format], version: '1.0.0' }
named import: 3.14
with createRequire: true
The module.exports object arrives as the default export. Whether named import works
depends on an inference: the runtime scans the source and guesses which names got
assigned. If the assignments happen inside a loop or with computed keys, this guess
fails, and only the default export can be used.
The reverse direction is more restricted. require is a synchronous call: it has
to return its value immediately. A standard module graph, though, can contain
top-level await, meaning its evaluation can be asynchronous. A graph like that
cannot be loaded with a synchronous call:
// file: pending.mjs const delay = await new Promise((settle) => setTimeout(() => settle('ready'), 10)); export const status = delay;
// file: pending-require.cjs — pending.mjs is the file above const m = require('./pending.mjs'); console.log(m.status);
$ node pending-require.cjs 2>&1 | grep '^Error' Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.
This boundary is a technical necessity, not a shortcoming: a synchronous call cannot wait for work whose completion spreads out over time. The solution the error message points to is the next lesson’s subject.
Summary
- In the call-based system, importing is a function call, exporting is writing to an object; the specifier can be computed at runtime, which is why static analysis cannot be done.
- The module wrapper provides scope isolation through an ordinary function scope, and
supplies the names
require,module,exportsfrom outside. - Loaded modules are cached by resolved path; the body runs once, and every call gets the same object.
- Exported primitive values are copies; a getter has to be defined for live-binding behavior.
- On a circular dependency, the call-based system gives a partially filled object; the standard system stops early access with an error.
- The extension and the type field decide which rule applies; a synchronous call cannot load a graph that contains top-level await.
Next Step
There is a form that does what a synchronous call cannot: an import that returns a promise, can be called at runtime, and can have a computed specifier. The next lesson takes up that form, the code-splitting and lazy-loading patterns, and the module registry’s role in those patterns.
To keep your progress and take notes, Log in
My notes
Log in to take notes.