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?
// clock.cjslet paused = false;function pause() { paused = true; }module.exports = { paused, pause };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

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?
// server.mjsconsole.log('server: start');import { profiles } from './profiles.mjs';import { cdn } from './cdn.mjs';console.log('server: ready', profiles.length, cdn);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?
// player.cjsexports.codec = 'av1';const drm = require('./drm.cjs');exports.play = () => 'playing';console.log('player: drm ready =', drm.ready);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?
// player.mjsimport { drmReady } from './drm.mjs';export const codec = 'av1';console.log('player: drm ready =', drmReady);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?
// stream.cjslet bitrate = 3000;function setBitrate(kbps) { bitrate = kbps; }module.exports = { bitrate, setBitrate };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?
// 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');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?
// ledger.mjsimport './fx.mjs';export function toMinor(amount) { return Math.round(amount * 100); }export const CURRENCY = 'EUR';Go deeper
Pro tips
- 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).
- Name the file for the module kind you mean. In a package whose
package.jsonsays"type": "module", a.jsfile is read as an ES module even if it assignsmodule.exports; name a CommonJS file.cjs(and an ES module.mjswhere the type is CommonJS). When: moving a package between module systems one file at a time. Verify (RAN,tips.mjs): importingcfg.jsfailed withSyntaxErrorat linking; the same code ascfg.cjsprintedeu.
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 torequire('foo')will get exactly the same object returned, if it would resolve to the same file". "multiple calls torequire('foo')will not cause the module code to be executed multiple times" (step 2). "When there are circularrequire()calls, a module might not have finished executing when it is returned"; "an unfinished copy of thea.jsexports object is returned to theb.jsmodule". 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. "thenameexport is copied off of themodule.exportsobject and set directly on the ES module namespace when the module is imported"; "Live binding updates or new exports added tomodule.exportsare 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.
Related lessons
Checked against the tools and docs as of 2026-10-13.