Modules: ESM and CommonJS, and what an import really gives you
require returns a cached exports object you copy values out of; import binds to the live variable. Caching, circular dependencies, and each system's rules.
After this lesson you can
- Tell ESM and CommonJS apart by syntax, loading and scope
- Explain why a module's top-level code runs only once
- Explain the difference between a copied export and a live binding
- Predict what a module sees in a circular dependency
Node has two module systems. CommonJS (CJS) is the original:
require() and module.exports. ES modules (ESM) are the language
standard: import and export, used by browsers, bundlers and modern
Node. In Node, a .mjs file is ESM, a .cjs file is CommonJS, and a
.js file is whichever the nearest package.json says with its
"type" field. With no "type" it is treated as CommonJS — unless it
contains import/export syntax, in which case current Node 22 loads it
as ESM instead (the second example shows it). Set "type" explicitly
rather than rely on that guess.
// CommonJS
const { readFile } = require("node:fs");
module.exports = { parse };
// ESM
import { readFile } from "node:fs";
export function parse() {}
The differences that matter
| CommonJS | ESM | |
|---|---|---|
| loading | require runs synchronously, wherever it is called | imports are found before any code runs, and loading may be asynchronous |
| imports | any expression, even inside an if | static import at top level; import() for dynamic loading |
| what you get | the module.exports object; destructuring copies values out of it | a live, read-only binding to the exporter's variable |
| strict mode | only if the file opts in | always |
top-level await | no | yes |
require, __dirname | available | not defined (use import.meta.url) |
Static imports are what let bundlers remove unused exports ("tree-shaking"): the full dependency graph is known without running anything.
Each module runs once
Both systems cache a module the first time it is loaded. Later
require calls or imports get the same module instance, and the
module's top-level code does not run again. That is why a module
holding a database pool or a counter acts as a singleton within one
process.
Copy versus live binding
const { count } = require("./counter") copies whatever count was at
that moment into a new local variable; later changes inside the module
do not reach it. An ESM import { count } is not a copy: it reads the
exporter's variable every time, so it always shows the current value —
and the importer cannot assign to it.
Circular dependencies
If a requires b while a is still running, and b requires a,
CommonJS does not loop forever: b gets a's exports object as it is
at that moment, partly filled. Anything a exports after its
require("b") line is missing when b looks. This works only because
the module is put in the cache before its code runs.
The first example writes small CommonJS files and loads them; the second
runs two ES modules, and then an untyped .js file, in a separate Node
process.
const fs = require("node:fs");const os = require("node:os");const path = require("node:path"); export function run() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cjs-")); const write = (name, src) => fs.writeFileSync(path.join(dir, name), src); globalThis.evaluations = []; write("counter.js", ` globalThis.evaluations.push("counter.js"); let count = 0; exports.count = count; exports.increment = () => { count++; exports.count = count; }; `); write("a.js", ` globalThis.evaluations.push("a.js"); exports.early = "set before requiring b"; const b = require("./b.js"); exports.late = "set after requiring b"; exports.bSaw = b.sawFromA; `); write("b.js", ` globalThis.evaluations.push("b.js"); exports.sawFromA = Object.keys(require("./a.js")); `); const counter = require(path.join(dir, "counter.js")); const { count } = require(path.join(dir, "counter.js")); counter.increment(); counter.increment(); const a = require(path.join(dir, "a.js")); return { evaluations: globalThis.evaluations, destructuredCount: count, propertyCount: counter.count, sameObject: counter === require(path.join(dir, "counter.js")), bSawFromA: a.bSaw, };}const fs = require("node:fs");const os = require("node:os");const path = require("node:path");const { execFileSync } = require("node:child_process"); export function run() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "esm-")); // One line in quotes: this sandbox rewrites any line of the example // itself that starts with "export", so the module text avoids that. const counterSource = "export let count = 0; export function increment() { count++; }"; fs.writeFileSync(path.join(dir, "counter.mjs"), counterSource); fs.writeFileSync(path.join(dir, "main.mjs"), ` import { count, increment } from "./counter.mjs"; const before = count; increment(); increment(); let assign; try { count = 5; } catch (e) { assign = e.name + ": " + e.message; } const waited = await Promise.resolve("top-level await works"); console.log(JSON.stringify({ before, after: count, assign, waited, typeofRequire: typeof require, typeofDirname: typeof __dirname, topLevelThis: String(this), })); `); const node = (file) => execFileSync(process.execPath, [path.join(dir, file)], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }).trim(); const result = JSON.parse(node("main.mjs")); // A .js file, no package.json anywhere above it, using import syntax. fs.writeFileSync( path.join(dir, "untyped.js"), 'import { sep } from "node:path"; console.log(typeof require);', ); result.untypedJsWithImport = node("untyped.js") === "undefined" ? "loaded as ESM" : "loaded as CommonJS"; return result;}Common mistakes
- Destructuring a value that the module changes later. With
require, the destructured copy is frozen at load time; readmod.countinstead, or export a function that returns it. - Assigning to an import.
count = 5on an imported binding throws aTypeError, as the second example shows. - Reaching for
__dirnameorrequirein an ES module. Neither exists; useimport.meta.urlandimport. - A circular import that reads an export at load time. It sees the partly filled exports (or, in ESM, a binding that is not initialised yet); move the access into a function called later.
What interviewers ask
- What are the main differences between CommonJS and ES modules? —
Synchronous runtime
requireversus static, possibly asynchronousimport; values copied out ofmodule.exportsversus live bindings; ESM is always strict and supports top-levelawait. - Why is a module a singleton? — It is evaluated once and cached; every importer shares the same instance.
- How does Node handle requiring requiring ? — The second returns 's exports as they are so far, partly filled, because is already in the cache.
Exercise · Pro
A tiny CommonJS loader
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade