If you work with JavaScript long enough, you eventually hit the split between CommonJS and ESM. Both solve the same broad problem, organizing code into modules, but they do it with different syntax, different loading semantics, and different runtime assumptions.
At a surface level, the distinction looks simple: CommonJS uses require() and module.exports, while ESM uses import and export. In practice, the real differences are deeper. They affect when modules are evaluated, whether imports are static or dynamic, how tooling performs optimization, and how Node.js resolves files at runtime.
CommonJS in one sentence
CommonJS is the older Node.js module system. It was designed for synchronous loading and server-side execution, which is why importing a module looks like a function call.
const math = require('./math');
console.log(math.sum(2, 3));
function sum(a, b) {
return a + b;
}
module.exports = { sum };
This model works well in Node because files are available locally and can be loaded synchronously.
ESM in one sentence
ESM, short for ECMAScript Modules, is the standard JavaScript module system. It is part of the language itself and is supported in browsers and Node.js.
import { sum } from './math.js';
console.log(sum(2, 3));
export function sum(a, b) {
return a + b;
}
Because import and export are part of the language grammar, the module graph can be analyzed before execution. That one design choice explains many of the practical differences between ESM and CommonJS.
Static imports vs runtime requires
The biggest technical difference is that ESM imports are static, while CommonJS imports are runtime expressions.
| Dimension | CommonJS | ESM |
|---|---|---|
| Import style | require() call executed at runtime |
import declaration analyzed before execution |
| Export style | module.exports object-based export |
export declarations with live bindings |
| Loading model | Synchronous by design | Linked before evaluation |
| Tooling analysis | Harder because dependencies can be dynamic | Easier because imports are static |
const moduleName = process.env.DEBUG ? './debug' : './prod';
const logger = require(moduleName);
The module to load is decided while the code is running. That flexibility is powerful, but it also makes the dependency graph harder to analyze ahead of time.
const loggerModule = await import('./logger.js');
loggerModule.logger('ready');
ESM keeps static imports for the normal path and uses import() when loading must be deferred to runtime.
Execution model and evaluation timing
1 CommonJS executes immediately
When Node hits require(), it resolves the file, runs it, caches it, and returns the exported value.
2 ESM links before evaluation
The runtime first resolves the module graph, then links it, then evaluates it with stricter ordering rules.
3 Top-level await fits ESM naturally
Because the module system is formalized, ESM can support features that do not fit well into classic synchronous CommonJS loading.
const config = await fetchConfig();
export default config;
Live bindings vs exported objects
ESM exports are live bindings. If the exporting module updates a binding, the importing module sees the updated value.
export let counter = 0;
export function increment() {
counter += 1;
}
import { counter, increment } from './counter.js';
console.log(counter); // 0
increment();
console.log(counter); // 1
CommonJS behaves differently because consumers receive the exported object returned by require(). Mutable state can still be shared, but the semantics are object-based rather than language-level live bindings.
Default interop confusion
One of the most common sources of confusion is interop between the two systems.
module.exports = function createServer() {
return {};
};
import createServer from './server.cjs';
That can work depending on the runtime and tooling, but the exact interop behavior is not identical across Node.js, Babel, and bundlers. This is why mixed codebases often end up with confusing edge cases around default, named exports, and transpiled helper code.
Resolution rules in Node.js
Node treats the two systems differently during resolution. Module format is not only about syntax. It is also about package-level configuration.
{
"type": "module"
}
Tooling and tree shaking
Bundlers prefer ESM because static imports make dependency analysis easier. Tree shaking works best when unused exports can be identified without executing code.
export function used() {
return 'used';
}
export function unused() {
return 'unused';
}
If only used is imported, a bundler can often eliminate unused from production output. That is much harder with CommonJS because require() is dynamic and export shapes can be assembled at runtime.
When each one still makes sense
| Use case | Better fit |
|---|---|
| Older Node.js codebase with broad package compatibility needs | CommonJS |
| New app using modern bundlers and browser-compatible modules | ESM |
| Need for top-level await and cleaner static analysis | ESM |
| Legacy server scripts built around synchronous require flows | CommonJS |
CommonJS is not obsolete, but ESM is usually the better default for new JavaScript projects because it is the standard format and aligns better with modern tooling.
Bottom line
CommonJS and ESM both organize JavaScript into modules, but they come from different eras and different assumptions. CommonJS is runtime-driven, synchronous, and Node-first. ESM is standard, statically analyzable, and better aligned with modern tooling.
If you understand that difference, most of the confusing syntax and interop issues start to make sense.