15 — Modules — CommonJS vs ESModules, and Why It Matters
"Import and export" was my module mental model, and it hid the split that explains most module errors. The idea that everything else hangs off: *JavaScript has two module systems — CommonJS (Node's original, synchronous require) and ESModules (the ES6 standard, statically-analyzed import) — and they don't interoperate cleanly, which is the entire reason bundlers and the .mjs/.cjs file extensions exist.* [1]
The framing that finally landed is the historical split. For most of JavaScript's life, there was no module system in the language — scripts just shared one global scope, and people invented patterns (IIFEs, AMD, CommonJS) to fake modules. Node picked CommonJS (require/module.exports) for the server. In 2015, ES6 standardized ESModules (import/export) as the language-level answer. Now both exist, both are in active use, and the friction between them is a daily reality.
CommonJS: require and module.exports
CommonJS is the system Node shipped with [2]. The shape:
// math.js
function add(a, b) { return a + b; }
module.exports = { add };
// app.js
const { add } = require("./math");
console.log(add(2, 3));Two traits define it. First, it's synchronous — require() reads the file and returns immediately, blocking the thread. That's fine on a server with local files; it would be unusable in a browser fetching over a network, which is why CommonJS never ran natively in browsers. Second, it's dynamic — require() is just a function, so I can call it inside an if and load conditionally. The cost of that dynamism: you can't statically analyze what a CJS module will load without running it.
ESModules: import and export
ESModules is the language standard, added in ES6 [3]. The shape:
// math.js
export function add(a, b) { return a + b; }
export default function multiply(a, b) { return a * b; }
// app.js
import multiply, { add } from "./math";
console.log(add(2, 3), multiply(2, 3));Two traits define it too, and they're the opposite of CommonJS. First, it's asynchronous by design — import declarations are resolved by the engine (and bundler) before the module body runs, supporting browser fetching. Second, it's static — imports must be at the top level, can't be inside conditionals, so the entire dependency graph is known at parse time. That static structure is what enables tree-shaking (bundler drops unused exports) and editor features like "go to definition" and autocomplete across files.
ESM also supports dynamic imports when I do need conditional loading: const mod = await import("./math") returns a promise. So the dynamism CJS had isn't lost — it's just opt-in.
Why they don't mix
The interoperability pain is the part that wasted hours of my life. CJS module.exports is "a single value, any type"; ESM export is "named bindings." A CJS module imported from ESM gets its module.exports as the default export — import pkg from "cjs-lib" works, but import { something } from "cjs-lib" only works if the runtime does extra analysis to detect named exports on the CJS module, which is best-effort and breaks for some patterns [3].
Node resolves this with rules: the package.json "type": "module" field, and the .mjs (always ESM) / .cjs (always CJS) file extensions. If I'm in an ESM project and need a CJS dependency, require isn't defined — I have to use createRequire or a dynamic import(). The rule of thumb: pick one system per project and let tooling (a bundler, or TypeScript) paper over the dependency graph.
What bundlers and tooling actually do
In real applications, raw module resolution in the browser is slow (one request per file) and the CJS/ESM split is messy. Bundlers — webpack, esbuild, Vite, Rollup — solve both: they walk the import graph at build time, understanding both systems, and emit a single optimized file. TypeScript does similar work for type-checking. So in day-to-day work, I write ESM import/export and let the tool handle whatever mix of dependencies the project has. The system matters most when something breaks at the module boundary — and that's when knowing which system is in play (and that there are two) saves the debugging time.
How I use this
Every new project starts as ESModules — "type": "module" in package.json, import/export only, top-level imports. I reach for CommonJS only when a dependency or tool genuinely requires it, and I isolate that to .cjs files rather than mixing in the same module. Dynamic import() handles the rare conditional-loading case. And the static-analysis payoff is the part I feel daily — tree-shaking keeps bundles small, and "go to definition" across files just works. The historical split is annoying, but picking ESM and letting tooling handle the rest is the modern default, and it's rarely wrong.
References
[1] I. Kantor, "Modules, introduction," The Modern JavaScript Tutorial, 2024. [Online]. Available: https://javascript.info/modules-intro
[2] RisingStack, "How the CJS module system works," Node.js at Scale, 2022. [Online]. Available: https://blog.risingstack.com/node-js-at-scale-module-system-commonjs-require/
[3] Mozilla, "JavaScript modules guide," MDN Web Docs, 2024. [Online]. Available: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
[4] Node.js, "ESModules in Node.js," Node.js Docs, 2024. [Online]. Available: https://nodejs.org/api/esm.html
[5] I. Kantor, "Export and Import," The Modern JavaScript Tutorial, 2024. [Online]. Available: https://javascript.info/import-export
Knowledge check · Question 1 of 5
What's the key structural difference between CommonJS and ESModules?
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!