---
title: CommonJS
source: 'https://academia.sh/en/courses/javascript-ecosystem/commonjs'
course: 'Modules, Tooling and the Ecosystem'
language: en
updated: '2026-08-17T18:09:45+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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:

```javascript
// 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**:

```javascript
// file: format-plain.cjs
module.exports = (measurement) => `word: ${measurement.wordCount}`;
```

```javascript
// 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**:

```javascript
// file: counter.cjs
console.log('counter.cjs body ran');
let count = 0;
module.exports = {
  increment() { count += 1; return count; },
  get value() { return count; },
};
```

```javascript
// 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:

```javascript
// file: record.cjs
let callCount = 0;
function count() { callCount += 1; }

module.exports = { callCount, count };
```

```javascript
// 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:

```javascript
// 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');
```

```javascript
// 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:

```javascript
// 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());
```

```javascript
// 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:

```javascript
// file: a2.mjs
import { bName } from './b2.mjs';

export const aName = 'A';
console.log('a2: bName =', bName);
```

```javascript
// 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:

```javascript
// file: legacy-format.cjs
function format(number) { return number.toFixed(2); }

module.exports = { format, version: '1.0.0' };
```

```javascript
// 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:

```javascript
// file: pending.mjs
const delay = await new Promise((settle) => setTimeout(() => settle('ready'), 10));
export const status = delay;
```

```javascript
// 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`, `exports` from 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.
