Two systems, both alive
ESM is the standard and the default for anything new. CommonJS is everywhere in existing code and in older packages, so you will read both for years yet.
| CommonJS | ES Modules | |
|---|---|---|
| Import | const x = require("./x.js") | import x from "./x.js" |
| Export | module.exports = … | export / export default |
| File extension | .js (default) or .cjs | .mjs, or .js with "type": "module" |
| Loading | Synchronous, at the call | Parsed first, then executed |
__dirname | yes | no — import.meta.dirname (20.11+) |
Top-level await | no | yes |
CommonJS
// math.js — one object, however you like to build it
function add(a, b) { return a + b; }
function mul(a, b) { return a * b; }
module.exports = { add, mul };
// or: exports.add = add; exports.mul = mul;
// app.js
const { add } = require("./math.js");
const math = require("./math.js"); // the SAME object — modules are cached
exports is just a variable pointing at module.exports. Reassigning it points the local variable somewhere else and exports nothing. Assign to module.exports, or add properties to exports.
ES Modules
- The file extension is required in Node, unlike in bundlers:
"./math.js", not"./math". - Imports are hoisted and resolved before any code runs, which is what makes static analysis and tree shaking possible.
- Named exports beat a default export: they can be autocompleted, renamed and searched for.
// math.js
export function add(a, b) { return a + b; }
export const PI = 3.14159;
export default class Calculator {}
// app.js
import Calculator, { add, PI } from "./math.js";
import * as math from "./math.js";
import { add as plus } from "./math.js";
// loaded only when the line runs, and returns a promise
const heavy = await import("./heavy.js");
Which one am I in?
Without "type": "module", every .js file is CommonJS. With it, require is not defined and import is. You can always be explicit with .mjs and .cjs.
{
"name": "doors",
"type": "module", // .js files are ESM; .cjs is still CommonJS
"main": "./src/app.js"
}
Resolution: where a name points
A bare name is searched for in node_modules in the current folder, then its parent, then its parent, up to the root. That walk is why a dependency installed three levels up still resolves — and why two versions of the same package can coexist.
require("./utils.js") // relative — a file next to this one
require("/etc/config.js") // absolute
require("express") // a package: node_modules, walking up the folders
require("node:fs") // a built-in — the node: prefix is explicit and faster to read
Modules are cached, and run once
This makes a module a convenient singleton — a database pool, a logger, a config object. It also means any code at the top level of a module is a side effect that fires the first time anyone imports it.
// counter.js
let n = 0;
module.exports = { next: () => ++n };
// two files requiring it share the same n
const a = require("./counter.js");
const b = require("./counter.js");
a.next(); b.next(); // 2
Circular imports
Neither module system makes this pleasant. The fix is structural: move the shared piece into a third module both can depend on.
// a.js
const b = require("./b.js"); // b.js runs now…
module.exports = { name: "a" };
// b.js
const a = require("./a.js"); // …and gets {} — a.js has not finished
console.log(a.name); // undefined
Interop, briefly
// ESM can import CommonJS — the exports arrive as the default
import pkg from "commonjs-package";
const { thing } = pkg;
// CommonJS cannot require ESM synchronously (before Node 22)
const mod = await import("./esm-module.js");