JavaScript Modules: ESM and CommonJS
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
One file holds you until about a thousand lines, then it does not. Modules solve three separate problems: scope isolation, explicit dependencies, and reuse. This chapter covers the format you should write today (ESM) and the one you still have to read (CommonJS).
Turning modules on
Node treats .js as CommonJS unless package.json says otherwise. One line changes the default for the whole package:
{
"name": "demo",
"type": "module"
}
Alternatives: .mjs is always ESM and .cjs is always CommonJS, which is handy in mixed repositories. In the browser, the module system only activates with <script type="module">.
Named exports
// math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export class Counter {
#n = 0;
inc() {
return ++this.#n;
}
}
// main.js
import { add, Counter, PI as pi } from "./math.js";
console.log(add(1, 2), pi);
Three details that bite people:
- The extension is required. Node’s ESM resolver does no extension guessing, and neither does the browser.
./mathfails where./math.jsworks. - Imports are hoisted.
importstatements run before the rest of the file regardless of where you put them, so put them at the top for the reader’s sake. - Imports are bindings, not copies. If the exporting module reassigns a
let, importers see the new value. That is how a counter module can export a live number.
Default exports
// logger.js
export default function log(msg) {
console.log(`[log] ${msg}`);
}
import log from "./logger.js";
The importer picks the name, so the same module can be log in one file and logger in another, and grep stops working. The pragmatic rule: one default export for the module’s main thing, named exports for everything else; a bag of utility functions should export nothing by default.
Mixing both is legal but confusing:
export default function format() {}
export const VERSION = "1.0.0";
import format, { VERSION } from "./format.js";
Re-exports and barrels
// index.js as the directory's public surface
export { add } from "./math.js";
export * from "./strings.js";
export { default as log } from "./logger.js";
export * does not forward default exports. A barrel file that re-exports three default exports silently exposes none of them as defaults.
Barrels also make stack traces worse and can pull in code you never needed; use them for a package’s public entry, not for every folder in the app.
Dynamic import
Static import only appears at the top level and pulls its dependencies in immediately. When you need code on demand — a large chart library, an optional feature, a branch that only runs on one platform — use import():
async function loadChart() {
const { Chart } = await import("./chart.js");
return new Chart();
}
import() returns a promise, so try/catch gives you a fallback path for free, and bundlers turn the call site into a separate chunk.
The specifier can be a variable, which is exactly what static imports cannot do:
const locale = navigator.language.startsWith("de") ? "de" : "en";
const messages = await import(`./locales/${locale}.js`);
CommonJS, the older format
Plenty of packages on npm still ship this shape:
// export
const { readFile } = require("node:fs/promises");
function parse(text) {
return JSON.parse(text);
}
module.exports = { parse };
// or a single function: module.exports = parse;
// import
const { parse } = require("./parse");
const parse2 = require("./parse"); // if it was module.exports = parse
| ESM | CommonJS | |
|---|---|---|
| Syntax | import / export |
require / module.exports |
| Dependency graph | static, resolved before execution | built at runtime |
| Exports | live bindings | value snapshot |
Top-level await |
allowed | not allowed |
| Strict mode | on by default | opt in with "use strict" |
| File extension | .mjs or type: module |
.cjs or the default |
The snapshot behaviour is the classic trap:
// counter.cjs
let n = 0;
module.exports = { n, inc: () => n++ };
const c = require("./counter.cjs");
c.inc();
console.log(c.n); // 0
module.exports captured the number 0, not a reference to n. To share mutable state under CommonJS, export an object and mutate its properties:
const state = { n: 0 };
module.exports = { state, inc: () => state.n++ };
Interop
- ESM importing CJS:
import pkg from "cjs-pkg"gives youmodule.exports. Named imports work only when Node’s static analysis (cjs-module-lexer) can detect the property names; when it cannot, fall back to the default import and destructure. - CJS importing ESM: since Node 22.12,
require()can load synchronous ESM. A module containing top-levelawaitstill cannot be required, so useawait import()in that path. - Dual loading: load the same package both ways and you can end up with two instances with separate state. A configuration object set in one copy and read from the other is invisible, which explains a whole category of “my setting is ignored” bugs.
// safest way to load ESM from CommonJS
const mod = await import("./esm-only.mjs");
The exports field
Library authors use it to define what the outside world may import, and to publish both formats:
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": "./dist/utils.js"
}
}
Once exports exists, unlisted paths stop resolving: my-lib/dist/internal.js fails even though the file is on disk. That encapsulation is intentional, but it is also why a dependency upgrade can suddenly break deep imports.
Put types first in each condition object — TypeScript picks the first match, and a conditions order that puts import first makes type resolution follow the wrong branch in some setups.
Circular imports
// a.js
import { b } from "./b.js";
export const a = "A";
console.log(b);
// b.js
import { a } from "./a.js";
export const b = "B";
console.log(a);
Both modules get a binding for the other, but the binding may still be uninitialised when the body runs: you get undefined, or a ReferenceError in the temporal dead zone. Which one you see depends on which module was evaluated first, so the bug can appear only after an unrelated import moves.
The fix is never setTimeout. Extract the shared piece into a third module both sides import, or move the call behind a function that imports lazily:
export async function logLater() {
const { format } = await import("./format.js");
return format();
}
Common mistakes
1. require and __dirname inside ESM
const path = require("node:path"); // ReferenceError: require is not defined
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
Node 22 also exposes import.meta.dirname and import.meta.filename, which removes the two-line dance in most cases.
2. A missing extension that only fails outside the bundler
import { add } from "./math" works in Vite and fails under plain node app.js. Write the extension everywhere and the behaviour stops depending on the tool.
3. Editing an imported module and expecting a reload
Node has no hot reload. Restart the process, or run node --watch app.js so the restart happens for you.
4. Mixing formats in one directory
If a package must contain both, make it explicit with extensions: .mjs is ESM, .cjs is CommonJS, no guessing.
5. Bare specifiers in the browser
import "lodash" in a browser module fails: there is no node_modules lookup. Either use a bundler, or declare an import map:
<script type="importmap">
{ "imports": { "lodash": "/vendor/lodash-es.js" } }
</script>
6. Importing for side effects and forgetting it
import "./polyfills.js" is legal and is the only way to run a module for its side effects, but bundlers may tree-shake a package whose sideEffects field is wrong. When a polyfill disappears from the build, check the dependency’s package.json.
Summary
- Write ESM for new code: static graph, live bindings, top-level
await. - Prefer named exports; one default export per module is a workable compromise.
import()is for on-demand loading and returns a promise you cantry/catch.- CommonJS exports are value snapshots, and dual loading can create two instances of the same package.
- Fix cycles by extracting a module, not by adding delays.
Next: async and promises — the microtask queue, and why await in a loop is usually a bug.