ESM vs CommonJS
JavaScript has two competing module systems: CommonJS (CJS), the format Node.js shipped with in 2009, and ES Modules (ESM), the official standard built into the language since ES2015 and supported natively by every modern browser and runtime. They look superficially similar but differ in how they load, when they resolve, and how they expose values. Understanding the gap matters because mixing them carelessly is the single most common source of “cannot use import statement outside a module” and “require is not defined” errors.
The core difference
CommonJS uses require() to pull in dependencies and module.exports to expose them. It is dynamic (you can call require anywhere, even conditionally) and synchronous (the file is read, executed, and returned before the next line runs). ES Modules use import / export, are static (declarations are analysed before any code runs), and load asynchronously, which is what lets browsers fetch modules over the network without blocking.
| Feature | CommonJS | ES Modules |
|---|---|---|
| Import syntax | const x = require('x') | import x from 'x' |
| Export syntax | module.exports = ... | export / export default |
| Resolution | Dynamic, runtime | Static, parse time |
| Loading | Synchronous | Asynchronous |
Top-level await | Not allowed | Allowed |
this at top level | module.exports | undefined |
| File extension | .js, .cjs | .js (with config), .mjs |
| Available in browsers | No | Yes |
| Live bindings | No (value copy) | Yes |
CommonJS syntax
// math.cjs
const add = (a, b) => a + b;
const PI = 3.14159;
module.exports = { add, PI };
// app.cjs
const { add, PI } = require('./math.cjs');
console.log(add(2, 3));
console.log(`PI is ${PI}`);
Output:
5
PI is 3.14159
Because require is just a function call, you can load modules lazily or behind a condition — handy, but it also means tooling cannot fully know your dependency graph ahead of time.
ES Module syntax
// math.mjs
export const add = (a, b) => a + b;
export const PI = 3.14159;
export default function multiply(a, b) {
return a * b;
}
// app.mjs
import multiply, { add, PI } from './math.mjs';
console.log(add(2, 3));
console.log(multiply(4, 5));
console.log(`PI is ${PI}`);
Output:
5
20
PI is 3.14159
A standout ESM-only feature is top-level await, which lets a module pause until an async value resolves before it finishes loading:
// config.mjs
const res = await fetch('https://api.example.com/config');
export const config = await res.json();
Telling Node which system to use
In a .cjs file Node always uses CommonJS; in a .mjs file it always uses ESM. For plain .js files, the nearest package.json decides via the "type" field:
{
"name": "my-app",
"type": "module"
}
With "type": "module", every .js file is treated as ESM. Omit the field (or set "type": "commonjs") and .js files stay CommonJS. The explicit .mjs / .cjs extensions always win over the "type" setting, which is useful when one file in a project needs to opt out.
Tip: When publishing a library, ship both formats and declare them in the
"exports"map ofpackage.jsonso consumers using either system get the right file automatically.
Interop and its pitfalls
ESM can import from CommonJS, but the reverse is awkward. A CommonJS module’s module.exports is exposed to ESM as the default export, and Node statically analyses named exports where it can:
// Importing a CommonJS module from ESM
import pkg from 'some-cjs-lib'; // module.exports
import { namedThing } from 'some-cjs-lib'; // works if Node detects it
Going the other direction, you cannot require() an ESM module synchronously in older Node versions — you must use a dynamic import(), which returns a promise:
// Loading ESM from CommonJS
async function load() {
const esm = await import('./feature.mjs');
esm.run();
}
load();
Warning: A frequent gotcha is
__dirnameand__filename. They exist in CommonJS but are undefined in ESM. Reconstruct them withimport.meta.url:import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);
Another subtle difference is live bindings. ESM imports are references to the original variable, so if the exporter reassigns it, importers see the new value. CommonJS copies the value at require time, so a later reassignment is invisible to consumers.
Best Practices
- Prefer ESM for new code — it is the standard, works in browsers and Node, and unlocks top-level
awaitand tree-shaking. - Be explicit with extensions: use
.mjs/.cjswhen a single file must override the project’s"type"setting. - When authoring libraries, publish both ESM and CJS builds and wire them up through the
"exports"field for maximum compatibility. - Replace
__dirname/__filenamewithimport.meta.urlhelpers when migrating CommonJS to ESM. - Use dynamic
import()to consume ESM from CommonJS rather than fighting the synchronousrequire. - Avoid mixing both systems in the same file; pick one per module and let
package.jsonset the default.