CommonJS vs ES Modules in Node.js: require vs import
CommonJS loads with a synchronous require and copies values; ES modules use static import, live bindings and top-level await. Interop, file types and cycles.
- Course: JavaScript study plan
- Module: Modules and Node
- Kind: Lesson
- Reading time: 14 min
- Runtime: Node 16
What is the difference between CommonJS and ES modules?
CommonJS is Node's original module system: require is a synchronous function call that returns a cached module.exports, and destructured values are copies. ES modules are the language standard: import and export are static, the module graph is linked asynchronously before it runs, and imports are live bindings. ES modules are always strict, allow top-level await, and have no require or __dirname.
Lesson
JavaScript had no module system for its first fifteen years; Node invented one (CommonJS, require/module.exports) and the language later standardised another (ES modules, import/export). Today both run in Node, they differ in ways that matter — synchronous versus asynchronous loading, copied values versus live bindings, __dirname versus import.meta.url — and most real projects mix them through the interop rules. This lesson covers what a module is (a file with its own scope), each system's mechanics and caching, the differences, the interop rules as they stand on Node 16, circular imports, and dynamic import().
A module is a file with private scope
Every file is a module: top-level const/function declarations are private to it, and only what it exports is visible outside. There is no shared global namespace to pollute (a browser <script> without type="module" is the exception — its top level is global). Modules are evaluated once and cached: two requires or imports of the same file return the same instance, so module-level state is a process-wide singleton — a cache, a connection pool, a config object.
CommonJS
// math.js
const PI = 3.14159;
function area(r) { return PI * r * r; }
module.exports = { PI, area }; // the exports object is what require() returns
// or: exports.area = area; (exports is an alias for module.exports — do not reassign `exports` itself)
// app.js
const math = require("./math"); // relative path: ./ or ../ — resolves .js, .json, .node, then a directory's index.js
const { area } = require("./math"); // destructure a copy
const fs = require("fs"); // a core module (also "node:fs")
const express = require("express"); // a package: walks up node_modules/ directories
require is a synchronous function call: the file is read, wrapped in function (exports, require, module, __filename, __dirname) { … }, executed, and module.exports is returned and cached in require.cache keyed by absolute path. Because it is a plain function you can call it conditionally, in a loop, with a computed path. Values are copied at the moment of require: a destructured count does not change when the module later changes its own count. JSON files can be required directly.
ES modules
// math.mjs (or .js with "type": "module" in package.json)
export const PI = 3.14159;
export function area(r) { return PI * r * r; }
export default class Circle { … }
// app.mjs
import Circle, { area, PI as pi } from "./math.mjs"; // default + named; extension REQUIRED in Node
import * as math from "./math.mjs"; // namespace object (frozen, live)
import fs from "node:fs"; // core modules
export { area } from "./math.mjs"; // re-export
Imports and exports are static — declared at the top level, resolved before any code runs — which lets bundlers tree-shake unused exports and lets the engine link modules before executing them. Loading is asynchronous: the whole graph is fetched and linked, then evaluated in dependency order. Bindings are live: import { count } reflects every later change the exporting module makes to count (you cannot assign to it from outside). ES modules are always strict mode, top-level this is undefined, await is allowed at the top level, and __dirname/__filename/require do not exist — use import.meta.url (a file:// URL string) with fileURLToPath, and createRequire(import.meta.url) when you must require.
Which system a file is
Node decides by extension and the nearest package.json: .mjs is always ESM, .cjs always CommonJS, .js follows "type" in package.json ("module" → ESM, absent or "commonjs" → CJS). Getting this wrong produces the two most-searched errors: Cannot use import statement outside a module (ESM syntax in a CJS file) and require is not defined in ES module scope (the reverse).
Interop on Node 16
- ESM importing CJS works:
import pkg from "./legacy.cjs"givesmodule.exportsas the default export; named imports of CJS work only for exports Node can detect statically (a heuristic — when it fails, import the default and destructure). - CJS requiring ESM does not work on Node 16 (
ERR_REQUIRE_ESM); useawait import("./x.mjs")from CJS — dynamic import works in both systems. (Node 22 later addedrequire(esm)for graphs without top-levelawait.) - A package can offer both through
"exports"conditions ("import"/"require") — the dual-package pattern, which risks two copies of the module's state if both are loaded.
Circular dependencies
A requires B, B requires A. CommonJS returns B the partial exports of A as filled in so far — often an empty object — so B sees undefined for things A defines later; ESM's live bindings are in their temporal dead zone until evaluated, so reading one throws ReferenceError: Cannot access 'x' before initialization. Both are symptoms of the same design problem; break the cycle (move the shared piece to a third module) or defer the use to call time. CommonJS's "sometimes undefined" is the nastier failure because it is silent.
Dynamic import()
const { default: heavy } = await import("./heavy.js"); // returns a promise of the namespace; works in CJS and ESM
if (needsChart) await import("chart.js"); // code-splitting: bundlers make it a separate chunk
const ns = await import("data:text/javascript,export const x = 1"); // any URL Node can load — handy for experiments
Use it for conditional or lazy loading, for loading ESM from CJS, and for plugins named at runtime. It is the only form of import that can appear inside functions.
Choosing, and migrating
New code: ESM ("type": "module"), the standard, the one browsers and TypeScript target, the one bundlers optimise. Existing Node projects: often still CJS, and fine. Libraries: ship ESM, add a CJS build only if users need require. Migrating a CJS codebase means renaming or setting "type", adding file extensions to relative imports, replacing __dirname/require.resolve/JSON requires, and dealing with any dependency that is ESM-only or CJS-only.
Common mistakes
- ESM syntax in a
.jsfile without"type": "module"(or the reverse). - Omitting the file extension in ESM relative imports (
import "./math"fails in Node; bundlers hid this). - Expecting a destructured CJS value to update; expecting an ESM import to be assignable.
requireof an ESM package on Node 16; expecting named imports from every CJS package.- Reassigning
exports = …(breaks the alias; assignmodule.exports). - Module-level state assumed per-import — it is per-process.
Interview angle
- "CommonJS versus ESM?" Synchronous
requirereturning copied values versus static, asynchronously-linkedimportwith live bindings; ESM is strict, supports top-levelawait, lacks__dirname/require. - "How does Node decide the module type of a file?" Extension (
.mjs/.cjs) or the nearestpackage.json"type". - "Can CJS require ESM?" Not on Node 16 — use
import(); ESM can import CJS (default =module.exports). - "What is a live binding?" An import that reflects the exporter's current value instead of a copy.
- "What happens with circular imports?" CJS hands out partially-filled exports; ESM throws on reading an uninitialised binding.
Key takeaways
- Every file is a private scope, evaluated once and cached — module state is process-wide.
- CJS:
require/module.exports, synchronous, dynamic, copies; ESM:import/export, static, asynchronous, live, strict,import.meta.url. - Type by
.mjs/.cjsorpackage.json"type"; extensions are mandatory in ESM imports. - ESM imports CJS (default export); CJS uses
import()for ESM on Node 16; dual packages risk duplicate state. - Break circular dependencies; use
import()for lazy, conditional and runtime-named loading.
Common questions
How does Node decide whether a file is CommonJS or an ES module?
By extension first, then the nearest package.json. A .mjs file is always an ES module and a .cjs file always CommonJS; a .js file follows the "type" field, where "module" means ESM and "commonjs" or no field at all means CommonJS.
How do I fix "Cannot use import statement outside a module"?
Node is reading ES module syntax in a file it treats as CommonJS. Rename the file to .mjs, or set "type": "module" in the nearest package.json. The reverse error, "require is not defined in ES module scope", means CommonJS code in a file Node treats as an ES module.
Can you require an ES module in Node.js?
Not on Node 16: calling require on an ES module throws ERR_REQUIRE_ESM, so CommonJS code loads it with await import(), which works in both systems. The other direction works, and an ES module importing a CommonJS file receives module.exports as the default export. Node 22 later allowed require of ES modules that use no top-level await.
What is a live binding in ES modules?
A live binding is an import that always reflects the exporting module's current value rather than a copy. If a module exports let count and later increments it, every import { count } sees the new number, and importers cannot assign to it. A value destructured from require in CommonJS is a copy and never updates.
What happens with circular imports in JavaScript?
CommonJS hands the second module the partially filled exports of the first, often an empty object, so it silently sees undefined. ES modules throw ReferenceError: Cannot access 'x' before initialization when a binding is read before it has been evaluated. The fix for both is to move the shared piece into a third module or defer its use to call time.
Exercises
Live bindings, observed
The starter defines an ES module as a data: URL exporting let count, inc() and a default. Import it with await import(url), print count before=<>, call inc() the number of times given on input, print count after=<> (the binding is live), import the same URL again and print sameInstance=<again === ns> default=<ns.default>, then namespaceKeys=<sorted keys>, then try ns.count = 99 and print assignToImport=<changed or the error's constructor name> stillCount=<ns.count>, and finally the CommonJS side of the same file: requireCached=<require("fs") === require("fs")> typeofRequire=<typeof require> dirnameIsString=<typeof __dirname === "string">.
Example: 3 →
count before=0
count after=3
sameInstance=true default=the default
namespaceKeys=count,default,inc
assignToImport=TypeError stillCount=3
requireCached=true typeofRequire=function dirnameIsString=trueImplement require's resolution algorithm
Over a virtual file tree, resolve CommonJS specifiers the way Node does. Input: file <absolute path> declares a file, pkg <dir> <main> declares a package.json with that main, require <fromDir> <specifier> asks for a resolution. Rules: a core module (fs, path, os, http, events, util, url, crypto, stream, with or without node:) → <core name>; a specifier starting with ./, ../ or / → normalise against fromDir, then try the exact path, .js, .json, then as a directory (its main, then index.js/index.json); a bare name → for fromDir and each parent up to /, try <dir>/node_modules/<name> with the same file-then-directory logic. Print <fromDir> require(<spec>) -> <path> or -> MODULE_NOT_FOUND. Use path.posix.
Example (excerpt): with /app/src/util.js, /app/src/lib/index.js, /app/node_modules/lodash/lodash.js + pkg /app/node_modules/lodash lodash.js:
/app/src require(./util) -> /app/src/util.js
/app/src require(./lib) -> /app/src/lib/index.js
/app/src/lib require(lodash) -> /app/node_modules/lodash/lodash.js
/app/src require(node:fs) -> <core fs>
/app/src require(express) -> MODULE_NOT_FOUNDIn this module: Modules and Node
- CommonJS and ES modules — two module systems, one runtime (this lesson)
- Packages, package.json, npm and semver
- The core modules you use every week — fs, path, os, process, util, events, url, crypto
- Buffers and streams — bytes, and data that does not fit in memory
- The process — arguments, environment, exit codes, signals, child processes and workers
- Checkpoint — Modules and Node
← Checkpoint — Asynchronous JavaScript · Packages, package.json, npm and semver →