CommonJS vs ES Modules
JavaScript has two module systems sitting on top of each other. CommonJS (require / module.exports) is what Node has shipped since the beginning and what older npm packages still use. ES Modules (import / export) are the standard built into the language since 2015. Modern Node supports both, browsers only support ES Modules, and many packages now ship both formats. Knowing the differences — and the gotchas at the boundary — saves a lot of debugging.
The two syntaxes side by side
CommonJS
// math.cjs
function add(a, b) { return a + b; }
const PI = 3.14159;
module.exports = { add, PI };
// or: exports.add = add; exports.PI = PI;
// app.cjs
const { add, PI } = require("./math.cjs");
console.log(add(2, 3));ES Modules
// math.mjs
export function add(a, b) { return a + b; }
export const PI = 3.14159;
// app.mjs
import { add, PI } from "./math.mjs";
console.log(add(2, 3));How Node decides which is which
Node looks at three things, in order:
File extension.
.cjsis always CommonJS;.mjsis always ESM.The nearest
package.json"type"field."type": "module"makes plain.jsfiles ESM;"type": "commonjs"(the default) makes them CommonJS.Per-file overrides win. A
.cjsfile inside a"type": "module"package is still CommonJS.
package.json — opt into ESM for plain .js
{
"name": "my-app",
"type": "module",
"main": "src/index.js"
}The real differences (not just syntax)
Load timing. CommonJS resolves at runtime —
requireruns synchronously the moment the line executes. ESM resolves statically at parse time, before any code runs. That is what allows tree shaking and top-levelawait.Value vs binding. CommonJS exports are a copy of
module.exportsat the moment require returns. ESM exports are live bindings — re-assigning an exportedletis visible to every importer.Cycles. Both handle circular dependencies, but with different rules. CommonJS may hand you a half-filled object; ESM hands you an uninitialised binding (Temporal Dead Zone) if you touch it too early.
File extensions. Native ESM requires the full path including
.js. CommonJS letsrequire("./math")findmath.js,math/index.js, etc.thisat the top level. CommonJS:module.exports. ESM:undefined.__dirname/__filename. Available in CommonJS for free. In ESM you build them fromimport.meta.url.Async. ESM modules are inherently asynchronous — they can
awaitat the top level. CommonJS is strictly synchronous.
The interop story
Most production headaches happen at the boundary.
Importing CommonJS from ESM (mostly works)
// CJS export
// module.exports = { add, PI };
// In an ESM file:
import pkg from "./math.cjs";
const { add, PI } = pkg;
// Or named imports (Node detects them when it can):
import { add, PI } from "./math.cjs";Importing ESM from CommonJS (must be async)
// You cannot use require() for an ESM module — it is async.
async function main() {
const { add } = await import("./math.mjs");
console.log(add(2, 3));
}
main();Dual packages
Many npm libraries ship both formats and let package.json decide which one a caller gets. The "exports" field is the modern way.
package.json — dual package
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json"
}
}Bundlers and Node pick the right entry automatically. Tools like tsup, unbuild and microbundle produce these dual builds for you.
__dirname and __filename in ESM
esm-paths.js
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname);Node 20+ also exposes import.meta.dirname and import.meta.filename directly, which removes the boilerplate.
Which should you write today?
New code — write ES Modules. They are the language standard, work in the browser, support top-level
await, and play nicely with TypeScript.Touching legacy Node? Stay with CommonJS until the surrounding code converts. Mixing inside one file is not possible.
Publishing a library? Ship dual — ESM as the main format, CJS for older consumers. A small build step is worth the compatibility.
Using TypeScript? Set
"module": "NodeNext"(or"Bundler") intsconfig.jsonand pick a"type"forpackage.jsonthat matches your output.