The module system you choose affects bundling, tree-shaking, and runtime behavior. Here's what I've learned shipping both at Google.
CommonJS (CJS)
// Synchronous, dynamic
const fs = require("fs");
module.exports = { readFile: fs.readFile };
// Can be conditional
if (process.env.NODE_ENV === "test") {
module.exports = require("./mock");
}CJS loads modules synchronously. require() is a function call — it can appear anywhere, even inside conditions. This makes static analysis impossible.
ES Modules (ESM)
// Static, async
import { readFile } from "fs/promises";
export { readFile };
// CANNOT be conditional
// if (x) import y from "z"; // SyntaxError!ESM imports are static declarations. The engine can analyze the entire dependency graph before execution — this enables tree-shaking.
Key Differences
| Feature | CJS | ESM |
|---|---|---|
| Loading | Synchronous | Asynchronous |
| Binding | Value copy | Live binding |
| this | module.exports | undefined |
| Tree-shaking | Not possible | Supported |
| Top-level await | No | Yes |
The Live Binding Gotcha
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from "./counter.mjs";
console.log(count); // 0
increment();
console.log(count); // 1 — live binding!
// In CJS, count would still be 0 (value copy)Interop Challenges
- CJS
require()of ESM: Not supported (use dynamicimport()instead) - ESM
importof CJS: Works but you get the wholemodule.exportsas default __dirnamedoesn't exist in ESM — useimport.meta.url