ESM vs CommonJS: JavaScript Modules Explained
ESM and CommonJS are JavaScript's two module systems — how import/export differs from require/module.exports, and when each one is used.
ESM (ECMAScript Modules, using import/export) and CommonJS (using require/module.exports) are the two ways JavaScript code gets split into files and wired back together. ESM is the language’s official, standardized module system and works natively in browsers and modern Node.js. CommonJS is older, Node-specific, and was the default for most of the ecosystem’s history — which is why so much existing code and tooling still assumes it.
Why there are two systems at all
JavaScript shipped without a module system for its first two decades. Browsers just loaded scripts in order and let them share the global scope. Node.js needed something better to manage server-side dependencies, so it adopted CommonJS in 2009 — a synchronous, require()-based system borrowed from ideas circulating in the server-side JS community at the time.
Browsers eventually needed real modules too, so TC39 standardized ESM as part of the language itself, landing in ES2015 and shipping natively in browsers a few years later. Node added ESM support afterward, which means a modern Node project can use either system, and a lot of confusion comes from code that mixes them.
Syntax differences
CommonJS uses function calls to import and a mutable object to export:
// math.js
function add(a, b) { return a + b; }
module.exports = { add };
// app.js
const { add } = require('./math.js');
ESM uses declarative keywords instead:
// math.js
export function add(a, b) { return a + b; }
// app.js
import { add } from './math.js';
The syntax looks similar, but the underlying behavior is quite different, and that difference is where most real-world friction comes from.
Static vs dynamic resolution
CommonJS’s require() is a regular function call, evaluated at runtime, anywhere in your code — you can call it conditionally inside an if block. ESM’s import is a static declaration: it must appear at the top level of a file and gets resolved before any code runs. That staticness is what lets tools perform reliable tree shaking — a bundler can see every import at build time and discard anything unused. CommonJS’s dynamic require() calls are much harder to analyze statically, so CommonJS bundles tend to carry more dead code.
ESM does support dynamic imports through the import() function, which returns a promise and is genuinely asynchronous — useful for code-splitting or loading a module conditionally without giving up static analysis for the rest of the file.
Synchronous vs asynchronous loading
require() is synchronous: it blocks execution until the required file is read and evaluated. That’s fine on a local filesystem but doesn’t map well onto a browser, where fetching a file over the network is inherently asynchronous — one reason browsers never adopted CommonJS. ESM’s loading model is asynchronous by design, which is also why top-level await is legal in an ESM file but not in CommonJS: the whole module graph is already resolved through promises before execution starts.
Live bindings vs copied values
This is the subtlest difference. CommonJS exports a snapshot: module.exports is an object, and once you destructure a value out of it, you have a copy. ESM exports live bindings: an imported name is a reference to the exporter’s binding, and if the exporting module updates that variable later, every importer sees the new value. This matters for patterns like mutable counters or configuration objects that change after import — behavior that’s easy to get wrong if you assume ESM works like CommonJS.
How to tell which one you’re using
Node decides how to interpret a .js file based on the nearest package.json’s type field: "type": "module" treats .js files as ESM, while "type": "commonjs" (or omitting the field) treats them as CommonJS. You can also force the interpretation per file with the .mjs or .cjs extensions, which override the package.json setting. Bundlers and frameworks — including Vite, which most modern Astro and frontend projects rely on — default to ESM and handle interop with CommonJS dependencies automatically.
ESM vs CommonJS at a glance
| CommonJS | ESM | |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Resolution | Dynamic, runtime | Static, compile-time |
| Loading | Synchronous | Asynchronous |
| Exports | Copied values | Live bindings |
| Top-level await | Not supported | Supported |
| Native browser support | No | Yes |
| Tree shaking | Limited | Full support |
Interop headaches
The practical pain point is mixing the two. An ESM file can import a CommonJS package fairly transparently — Node wraps its module.exports as a default export. Going the other direction is harder: a CommonJS file cannot require() a package that only ships ESM, because require() is synchronous and ESM loading is not. The usual workaround is a dynamic import() call, which returns a promise instead of a plain value. This asymmetry is the single most common source of “Cannot use import statement outside a module” and “ERR_REQUIRE_ESM” errors, and it’s largely why the ecosystem’s slow migration to ESM-only packages has taken years — maintainers have to weigh breaking CommonJS consumers against giving up the benefits of native modules. It’s a similar tradeoff to the one covered in Bun vs Node, where runtime-level module handling is itself a differentiator.
Which one should you use
For new projects, default to ESM. It’s the standard, it’s what browsers run natively, it enables better tooling, and every major framework and bundler assumes it. Set "type": "module" in package.json, use import/export throughout, and reach for dynamic import() for the rare case where you need a module conditionally. You’ll still require() older CommonJS-only packages indirectly through your bundler’s interop layer, but you shouldn’t need to write new CommonJS code — a decision worth locking in with semantic versioning discipline if you’re publishing a package, since switching a package’s module format later is a breaking change for its consumers.
The main exception is legacy Node scripts and tooling configs that predate widespread ESM support — some of those still expect CommonJS, and there’s rarely a reason to migrate a small script just for the sake of it.
The takeaway
CommonJS and ESM solve the same problem — splitting code into reusable modules — but ESM does it as a language standard with static, analyzable imports, live bindings, and native async loading, while CommonJS is a Node-specific, dynamic, synchronous system that predates the standard. New code should default to ESM; the main reason to touch CommonJS today is interop with older packages that haven’t made the switch.
Tagged
Keep reading
Takina · · 4 min read Node.js Streams Explained: Readable, Writable, and Piping
Node.js streams process data in chunks instead of loading it all into memory. How readable, writable, and transform streams work, and when to reach for them.
Takina · · 5 min read Bun vs Node.js in 2026: Which Runtime Should You Use?
An honest comparison of Bun and Node.js in 2026 — speed, ecosystem, built-in tooling, and when each runtime actually wins.
Takina · · 4 min read TypeScript Abstract Classes, Explained
Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.