Skip to content

ES modules vs CommonJS

require hands you a parcel packed once; import gives you a window onto the exporter's own shelf.

Predict first

Before you read: predict

ExamHall's proctor service is half-way through a move from CommonJS to ES modules, so its pause clock exists in both forms:

// room.cjs
const clock = require('./clock.cjs');
clock.pause();
console.log('room.cjs', clock.paused);
// clock.mjs
export let paused = false;
export function pause() { paused = true; }
// room.mjs
import * as clock from './clock.mjs';
clock.pause();
console.log('room.mjs', clock.paused);

node room.cjs and node room.mjs each run once.

Which lines print true?

Code for this question
// clock.cjslet paused = false;function pause() { paused = true; }module.exports = { paused, pause };
Pick one

The 30-second version

require() runs a file when the call runs, once per process, and returns its module.exports object; values put into that object are copies. ES modules are linked first, and imported modules run before the importer's body; an import reads the exporter's variable itself. Cycles differ: CommonJS hands over unfinished exports; ES modules throw on reading a binding not yet set.

require hands you a parcel packed once; import gives you a window onto the exporter's own shelf.

How it works

Step 1 of 11

require runs at the call. require('./x.cjs') loads and runs the file when that line runs, and returns the file's module.exports.

Analogy: A racecourse's tote odds board

At the racecourse In Node
a punter copies the 2:30's odds into her notebook and keeps them when the board changes a value destructured from require() (steps 3–4)
a punter who looks up at the board each time sees the new odds reading config.startsOpen at use time (step 4)
every screen in the stands is wired to the tote before the first race and shows its figures as they change; only the tote office can change them ES module imports: linked first, live, read-only (steps 5, 7)
the runners for an earlier race are declared before the race that depends on them starts dependencies run first (step 6)
a clerk who asks the stewards for their runner list while they wait on his own sheet is handed their half-written list a CommonJS cycle (step 8)

In real terms: when two parts of a service disagree about a setting, find where each one read it, and whether it holds a copy.

Breaks down when: a punter can ring the tote and be told the current odds; a CommonJS value copied into a variable has no line back to its module.

Contrasting case: Python's from config import starts_open behaves like a destructured require: after close_starts() the name still holds True, while config.starts_open reads False.

Examples, run for real

4.1 ExamHall: starts accepted after the close

EXH-3720: at 09:55 an admin closed exam starts through config.closeStarts(), which sets config.startsOpen to false. /status reported closed, yet the start route accepted three more candidates. The route (routes/start.js, run here as start.cjs) had taken const { startsOpen } = require('./config.cjs') when it loaded. MR !4020 keeps the module object and reads config.startsOpen inside the handler.

4.1 09:55 config.closeStarts(), then GET /status and 3 exam starts (C-101, C-102, C-103)
  as shipped: const { startsOpen } = require('./config.cjs')   /status closed · starts 200 200 200 · accepted 3
  MR !4020:   const config = require('./config.cjs')           /status closed · starts 503 503 503 · accepted 0

/status read the property on every request and saw the write. The route had copied true into its own constant at load (step 4), so nothing closeStarts() did could reach it.

4.2 What the MR relies on

The fix works because closeStarts() writes to the exported object itself. A module that changes one of its own variables instead never updates its export (step 3), and reading the property at use time would not help.

Picture it

Concept illustration for this lesson.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A video service starts with node server.mjs:

// profiles.mjs
import { cdn } from './cdn.mjs';
console.log('profiles: built for', cdn);
export const profiles = ['480p', '720p', '1080p'];
// cdn.mjs
console.log('cdn: configured');
export const cdn = 'edge-eu';

In what order do the first three lines begin?

Code for this question
// server.mjsconsole.log('server: start');import { profiles } from './profiles.mjs';import { cdn } from './cdn.mjs';console.log('server: ready', profiles.length, cdn);
Pick one

Question 2 of 6

A player module and a DRM module require each other; the service starts with node player.cjs:

// drm.cjs
const player = require('./player.cjs');
console.log('drm:', typeof player.play, player.codec);
exports.ready = true;

What does the drm: line print?

Code for this question
// player.cjsexports.codec = 'av1';const drm = require('./drm.cjs');exports.play = () => 'playing';console.log('player: drm ready =', drm.ready);
Pick one

Question 3 of 6

The team moves the same pair to ES modules and starts with node player.mjs:

// drm.mjs
import { codec } from './player.mjs';
console.log('drm: codec =', codec);
export const drmReady = true;

What happens?

Code for this question
// player.mjsimport { drmReady } from './drm.mjs';export const codec = 'av1';console.log('player: drm ready =', drmReady);
Pick one

Question 4 of 6

A player raises its stream's bitrate, but the log still shows the old one:

// player.cjs
const stream = require('./stream.cjs');
stream.setBitrate(6000);
console.log('player sends', stream.bitrate);

node player.cjs prints player sends 3000.

Which one change makes it print player sends 6000?

Code for this question
// stream.cjslet bitrate = 3000;function setBitrate(kbps) { bitrate = kbps; }module.exports = { bitrate, setBitrate };
Pick one

Question 5 of 6

A box office loads its payments module only when it sells a seat; it starts with node box-office.cjs:

// payments.cjs
console.log('payments: loaded');
exports.charge = (seat) => console.log('payments: charged', seat);

How many times does payments: loaded print, and where does it first appear?

Code for this question
// box-office.cjsconsole.log('box office: boot');function sell(seat) {  const payments = require('./payments.cjs');  return payments.charge(seat);}console.log('box office: listening');sell('A1');sell('A2');
Pick one

Question 6 of 6

A ledger and its currency helper import each other; the service starts with node ledger.mjs:

// fx.mjs
import { toMinor, CURRENCY } from './ledger.mjs';
console.log('fx: 12.5 ->', toMinor(12.5));
console.log('fx: currency', CURRENCY);

What does the run print before it ends?

Code for this question
// ledger.mjsimport './fx.mjs';export function toMinor(amount) { return Math.round(amount * 100); }export const CURRENCY = 'EUR';
Pick one

Go deeper

Pro tips
  1. Keep the module object when a setting can change. For a CommonJS setting that another module changes at runtime, require the module once and read its property where the decision is made, not into a constant at load. When: kill switches, feature flags, maintenance modes (steps 4, 11).
  2. Name the file for the module kind you mean. In a package whose package.json says "type": "module", a .js file is read as an ES module even if it assigns module.exports; name a CommonJS file .cjs (and an ES module .mjs where the type is CommonJS). When: moving a package between module systems one file at a time. Verify (RAN, tips.mjs): importing cfg.js failed with SyntaxError at linking; the same code as cfg.cjs printed eu.
Source
  • Node.js v24 API, Modules: CommonJS modules, sections "Caching" and "Cycles" (source doc/api/modules.md, branch v24.x): https://nodejs.org/docs/latest-v24.x/api/modules.html · fetched 2026-10-13 (sources/modules.raw). The curriculum row's second source. "Modules are cached after the first time they are loaded. This means (among other things) that every call to require('foo') will get exactly the same object returned, if it would resolve to the same file". "multiple calls to require('foo') will not cause the module code to be executed multiple times" (step 2). "When there are circular require() calls, a module might not have finished executing when it is returned"; "an unfinished copy of the a.js exports object is returned to the b.js module". Valid as of 2026-10-13.
  • Node.js v24 API, Modules: ECMAScript modules, section "CommonJS Namespaces" (source doc/api/esm.md, branch v24.x): https://nodejs.org/docs/latest-v24.x/api/esm.html · fetched 2026-10-13 (sources/esm.raw). The curriculum row's first source. "the name export is copied off of the module.exports object and set directly on the ES module namespace when the module is imported"; "Live binding updates or new exports added to module.exports are not detected for these named exports." Teaching background only: no item imports a CommonJS file by name; the Node page does not itself state ES module evaluation order or live bindings, which the next source does. Valid as of 2026-10-13.
  • MDN, import (source files/en-us/web/javascript/reference/statements/import/index.md, mdn/content main): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import · fetched 2026-10-13 (sources/mdn-import.raw). Not named by the row; it supports teaching only, and every item is executed. "Import declarations are hoisted. In this case, that means that the identifiers the imports introduce are available in the entire module scope, and their side effects are produced before the rest of the module's code runs" (step 5). "The imported bindings are called live bindings because they are updated by the module that exported the binding, but cannot be re-assigned by the importing module" (step 7). Valid as of 2026-10-13.

Checked against the tools and docs as of 2026-10-13.