JavaScript CommonJS vs ES Modules
JavaScript has two competing systems for splitting a program into separate files: CommonJS (CJS), the require() and module.exports system Node.js has used since 2009, and ECMAScript Modules (ESM), the standardized import/export syntax that is now part of the JavaScript language itself. Both let you break code into reusable pieces and share values between files, but they resolve paths, load files, and even execute code differently under the hood. Because most real Node.js projects, npm packages, and build tools still have to deal with both systems, often in the very same codebase, knowing exactly how they differ, and how to bridge them, is essential.
Overview: Two Module Systems, One Language
CommonJS was invented for Node.js before JavaScript had any built-in module system of its own. It works by treating every file as a function: Node wraps your file code in a hidden wrapper function that receives exports, require, module, __filename, and __dirname as arguments, then runs that function synchronously, top to bottom, the moment the file is required. Whatever you attach to module.exports before the function finishes becomes the value the caller receives. Because require() is just a regular function call, it can appear anywhere, inside an if block, inside a loop, even built from a computed string, which makes CommonJS flexible but also hard to analyze statically.
ES Modules were standardized in ES2015 as part of the JavaScript language specification itself, designed to work identically in browsers and in Node.js. Instead of executing top to bottom in one pass, the engine processes an ES module in distinct phases: it first parses the file and every file it statically imports into a dependency graph, without running any code, called construction; it then allocates storage for every exported and imported binding and links them together, called instantiation; only then does it actually run each module’s code, exactly once, in dependency order, called evaluation. Because import and export are declarations, not function calls, they must appear at the top level of a file and their names must be literal. This static shape is what lets bundlers safely delete exports nobody imports, and what lets the engine report a missing export as an error before any code runs at all.
The two systems also disagree about how values cross the module boundary. A CommonJS require call returns whatever module.exports referenced at that exact moment; destructuring a primitive out of it gives you a disconnected copy. An ES Module import instead creates a live binding, a direct reference to the exporting module’s own variable, so a later reassignment is visible to every importer immediately. ES Modules also run in strict mode automatically and have no this, __dirname, or __filename at the top level, while CommonJS files are loose mode by default and have all of those available.
Syntax
The table below lines up the equivalent syntax for the most common tasks in each system.
| Task | CommonJS | ES Modules |
|---|---|---|
| Export one value | module.exports = value; |
export default value; |
| Export several named values | exports.name = value; |
export const name = value; |
| Import everything | const mod = require('./file'); |
import * as mod from './file.js'; |
| Import specific names | const { a, b } = require('./file'); |
import { a, b } from './file.js'; |
| File extension | Optional, .js assumed | Required in relative paths |
| Loading | Synchronous, at the call site | Asynchronous under the hood, statically linked before execution |
| Top-level await | Not allowed | Allowed |
| Module identity | this equals module.exports |
this is undefined |
| File or dir path | __filename, __dirname |
import.meta.url |
| Enabling in Node.js | Default for .js, or explicit .cjs | .mjs extension, or type module in package.json |
Examples
Example 1: A CommonJS Module
A CommonJS module attaches whatever it wants to share onto module.exports. Any other file loads it with require(), which resolves the path, runs the file once, caches the result, and returns module.exports.
// mathUtils.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = { add, subtract };
// app.js
const { add, subtract } = require('./mathUtils');
console.log(add(5, 3));
console.log(subtract(5, 3));
Output:
8
2
require('./mathUtils') resolves to mathUtils.js automatically, no extension needed, runs it synchronously the first time it is requested, and returns the object assigned to module.exports. Destructuring pulls two independent references to those two functions out of that returned object.
Example 2: The Same Thing as an ES Module
The ES Module equivalent uses export and import declarations instead of a runtime object. Node treats a file as an ES module when it has a .mjs extension, used below, or when the nearest package.json has type set to module.
// mathUtils.mjs
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export default function multiply(a, b) {
return a * b;
}
// app.mjs
import multiply, { add, subtract } from './mathUtils.mjs';
console.log(add(5, 3));
console.log(subtract(5, 3));
console.log(multiply(5, 3));
Output:
8
2
15
Notice two syntax differences from the CommonJS version: the .mjs extension is mandatory in the import path, and a module can combine one default export, imported without braces under any local name you like, here multiply, with any number of named exports, imported with braces using their exact declared names.
Example 3: Live Bindings vs. Copied Values
The most consequential difference between the two systems shows up when an exported value changes after it is first loaded. Here is the CommonJS version:
// counterCJS.js
let count = 0;
function increment() {
count++;
}
module.exports = { count, increment };
// mainCJS.js
const { count, increment } = require('./counterCJS');
console.log(count);
increment();
console.log(count);
Output:
0
0
Even though increment() really does change the internal count variable inside counterCJS.js, mainCJS.js still logs 0 the second time. Destructuring copied the primitive number out of the returned object at the moment require() ran; the local count in mainCJS.js has no ongoing connection to the module.
Now the ES Module version:
// counter.mjs
export let count = 0;
export function increment() {
count++;
}
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count);
increment();
console.log(count);
Output:
0
1
This time the second log shows 1. The imported count is not a copy, it is a live binding to the exact same storage location as count inside counter.mjs. When increment() changes it, every importer observes the change, even though main.mjs never reassigned count itself, and in fact is not allowed to; only the exporting module can write to it.
How It Works Step by Step: Under the Hood
CommonJS load sequence: when Node hits require('./mathUtils'), it resolves the specifier to an absolute file path, trying the exact path, then .js, .json, or .node extensions, then index.js inside a directory. It checks require.cache for that resolved path; if the module was already loaded, the cached module.exports is returned instantly with no re-execution. Otherwise it reads the file, wraps its text in a hidden function that receives exports, require, module, __filename, and __dirname, and calls that function synchronously. Whatever the file assigned to module.exports during that call is cached and returned. Because this happens synchronously, a long chain of nested require calls blocks execution until every one finishes.
ES Module load sequence: Node or a browser first performs construction: starting from the entry file, it statically scans every import declaration without executing anything, recursively fetching and parsing each imported file into a full module graph. Next comes instantiation: for every export and import in the graph, the engine allocates a binding slot and wires each import to point at the matching export’s slot. This is what makes bindings live, and why a mistyped or missing named import fails before any module body runs. Finally comes evaluation: each module’s top-level code runs exactly once, in dependency order, and import statements behave as if hoisted, since linking already happened. This phased design is also why top-level await is allowed in ESM: evaluation can pause a module, and everything waiting on it, without blocking the whole process the way a synchronous require would.
Interop between the two: Node lets an ES module import a CommonJS file; the whole module.exports object arrives as the default import. The reverse is not directly possible: a CommonJS file cannot require a genuine ES module, because require is synchronous and ES modules load asynchronously. The supported bridge is dynamic import(), which returns a promise and works from any CommonJS file.
Common Mistakes
Mistake 1: Reassigning exports instead of module.exports. Inside the CommonJS wrapper function, exports starts out as just a convenience variable pointing at the same object as module.exports. Assigning a brand-new object directly to exports breaks that connection; module.exports, the thing require() actually returns, is left unchanged.
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
exports = { add, subtract };
Any file that requires this module still gets whatever module.exports was before, an empty object by default, not { add, subtract }. Fix it by assigning to module.exports directly, or by attaching properties onto the existing exports object instead of replacing it:
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = { add, subtract };
Mistake 2: Omitting the file extension in an ES Module import. CommonJS require('./mathUtils') happily guesses the .js extension for you, but native ES Module resolution in both Node and browsers does not.
import { add } from './mathUtils';
console.log(add(2, 2));
This throws a module-resolution error before add is ever called, because ESM treats ./mathUtils as a literal, complete specifier; it will not try appending .js. Always spell out the extension:
import { add } from './mathUtils.mjs';
console.log(add(2, 2));
Mistake 3: Trying to use require() inside a file that is being loaded as an ES module. Once a file is an ES module, via .mjs or type module, the CommonJS-only globals require, module, exports, __filename, and __dirname simply do not exist in that scope.
// mistake.mjs
const fs = require('fs');
console.log(typeof fs.readFileSync);
This throws a ReferenceError as soon as it runs. Inside an ES module, load built-in and third-party modules with static import, or, if you need to load something conditionally or at runtime, use the dynamic import() function instead:
const loadFs = async () => {
const fs = await import('fs');
console.log(typeof fs.readFileSync);
};
loadFs();
Best Practices
- Pick one module system per project, and set it explicitly with type module or type commonjs in
package.jsonrather than relying on Node’s fallback guessing. - When mixing systems in one project, use the
.mjsand.cjsextensions so each file’s module type is unambiguous at a glance. - Always include the file extension in relative ES Module import specifiers, such as
./utils.js, not./utils; CommonJS’s extension-guessing does not carry over. - Treat imported bindings, and required object properties, as read-only by convention; both systems allow surprising action at a distance if a shared object is mutated carelessly.
- When authoring an npm package for both worlds, use the
exportsfield inpackage.jsonwith conditional import and require entries so each consumer gets the right build automatically. - Reach for dynamic
import(), not arequire()hack, whenever a CommonJS file needs to load an ES-only module. - Prefer ES Modules for new projects: they are statically analyzable for better tree-shaking and tooling, standardized across browsers and Node, and support top-level
await.
Practice Exercises
- Write a CommonJS module
stringUtils.jsexportingcapitalize(str)andreverse(str)viamodule.exports, plus a file that requires it and logs both results for the string ‘hello’. Then rewrite both files as ES Modules using named exports. - Predict, then explain:
settings.mjsexports a mutablelet themeequal to ‘light’ and a functionsetTheme(next)that reassigns it. A second file importsthemeandsetTheme, logstheme, callssetTheme('dark'), and logsthemeagain. What does the second log print, and why, given the importing file never reassignedthemeitself? - In a project with no type field in
package.json, writegreet.mjsexporting agreet(name)function, and a separate .js CommonJS file that loads it with dynamicimport()inside an async function. Explain why a plainrequireofgreet.mjswould fail.
Summary
- CommonJS loads files synchronously, running each one the moment it is requested, and caches the result.
- ES Modules are parsed and linked statically before any code runs, execute in strict mode automatically, and support top-level
await. - CommonJS hands importers a value copied out of
module.exportsat require time; ES Modules hand importers a live binding to the exporting module’s own variable. - ESM import specifiers require the file extension; CommonJS
require()guesses it for you. - An ES module can import a CommonJS file as its default export, but a CommonJS file cannot
require()a genuine ES module; it must use dynamicimport()instead. - Signal a file’s module type in Node.js with the
.mjs,.cjsextensions or the type field inpackage.json, and avoid relying on defaults in mixed projects.
