JavaScript Modules: ESM vs CommonJS
A practical comparison of ES Modules and CommonJS: how they load, how they differ, interop strategies, and how to choose for a new project.
What you'll learn
- ✓How ESM and CommonJS load modules
- ✓The static vs dynamic loading difference
- ✓Interop rules between the two
- ✓Dual-package hazards
- ✓How to choose for a new project
Prerequisites
- •Comfortable with JavaScript files and imports
For years, Node was CommonJS and the browser was script tags. Then bundlers brought ES Modules (ESM) to the browser, Node adopted ESM, and now most projects live somewhere in between. The two systems look similar on the surface but differ in important ways. Knowing those differences keeps you out of the dual-package swamp.
What and why
CommonJS (CJS) is Node’s original module system. You use require() to load and module.exports to expose. Loading is synchronous and dynamic: require runs at the moment it executes, and the path can be computed at runtime.
ES Modules (ESM) are the standard JavaScript module system, defined in the ECMAScript spec. You use import and export. Imports are static: their names and paths are resolved before any code runs, which enables tree-shaking and circular-dependency analysis at build time.
Both exist because they evolved independently. The browser needed something safe for the network (declarative, async, fetchable). Node needed something quick for the file system (synchronous, dynamic). ESM eventually won as the standard.
Mental model
CJS is a function call: when control reaches require('x'), Node loads x, runs it top to bottom, and hands you the result. ESM is a graph: the engine reads import statements, builds a dependency graph, fetches and parses all modules, then executes them in order.
CommonJS (sync, dynamic):
run code -> hit require('x') -> load x synchronously -> continue
ESM (static graph):
parse all imports -> build dependency graph
|
v
fetch & parse every module in graph
|
v
execute in topological order This is why you can wrap require in a conditional but you cannot wrap a top-level import statement. ESM has a dynamic import() for runtime loading, but it returns a promise.
Hands-on example
CommonJS:
// math.cjs
function add(a, b) { return a + b; }
module.exports = { add };
// app.cjs
const { add } = require('./math.cjs');
console.log(add(2, 3));
ESM:
// math.mjs
export function add(a, b) { return a + b; }
// app.mjs
import { add } from './math.mjs';
console.log(add(2, 3));
In Node, the file extension or the nearest package.json "type" field decides which system applies. .mjs is always ESM. .cjs is always CJS. .js follows package.json: "type": "module" means ESM, otherwise CJS.
Interop
You can import a CJS module from ESM:
import lodash from 'lodash'; // default import gets module.exports
import { merge } from 'lodash'; // named imports work for many CJS modules via static analysis
You cannot use synchronous require to load ESM. The escape hatch is dynamic import:
const mod = await import('./esm-only.mjs');
mod.something();
Bundlers (Webpack, Vite, esbuild) paper over a lot of this by transforming both formats into their own internal module representation. In raw Node, the rules are stricter.
Dual-package hazards
Publishing a library that supports both ESM and CJS introduces “dual instance” risk: if a user imports the ESM build and another dependency requires the CJS build, you end up with two copies of the module in memory. Classes, singletons, and instanceof checks break across the boundary.
{
"name": "my-lib",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
To minimize hazards, keep stateful singletons out of dual-exposed packages, or ship ESM only and tell users to upgrade.
Common pitfalls
- Top-level
awaitin CJS. Not allowed. Works only in ESM. __dirnameand__filenamein ESM. Not defined. Useimport.meta.urlandfileURLToPathinstead.- Forgetting file extensions in ESM. Node ESM requires the explicit
.jsor.mjs. Bundlers often hide this; Node does not. - Mixing
module.exportsandexportin the same file. They are different worlds.
Best practices
- For new Node projects, default to ESM. Set
"type": "module"and writeimport/export. - For libraries with broad reach, ship dual builds via the
exportsfield, with explicitimportandrequireconditions. - Use dynamic
import()when you need conditional or lazy loading. - Avoid putting mutable state in module top-level scope of dual-published packages.
FAQ
Is ESM slower than CJS in Node? Historically yes, but modern Node has closed most of the gap. Differences are usually irrelevant outside microbenchmarks.
Can I use import in a .js file without "type": "module"? No. Node will throw a syntax error. Either change the extension to .mjs or set the type.
Do bundlers prefer ESM? Yes. Tree-shaking depends on static import statements, so ESM enables smaller bundles in practice.
Is CJS deprecated? Not formally, but the ecosystem is moving toward ESM-first. Many new packages publish ESM only.
Related articles
- JavaScript The JavaScript Event Loop, Explained Clearly
A deep but practical look at the JavaScript event loop: call stack, microtasks, macrotasks, and how async code actually runs in the browser and Node.
- Node.js Node.js Async Iterators Tutorial
Master async iterators in Node.js for streaming files, paginated APIs, and backpressure-aware data processing.
- Node.js Debugging Node.js Memory Leaks
Find and fix memory leaks in Node.js using heap snapshots, sampling, and a few reliable patterns to avoid leaks.
- Node.js Node.js Pino Logging Tutorial
Set up structured, high-performance logging in Node.js with Pino, including child loggers, redaction, and pretty-printing for development.