CommonJS vs ES modules
By FrontendPro Editorial Team Updated 9/26/2026
Answer
Both split code into reusable files, but they load it in fundamentally different ways — and that difference explains every other difference between them.
// CommonJS
const { value } = require("./config");
module.exports = { start };
// ES modules
import { value } from "./config.js";
export { start };
Static vs dynamic
import is a declaration. It is resolved before the module body executes, so tools can see the whole dependency graph without running anything — which is what makes tree shaking and top-level await possible.
require() is an ordinary function call. It happens at runtime, can sit inside an if, and can take a computed path. That flexibility is exactly what prevents static analysis.
| CommonJS | ES modules | |
|---|---|---|
| Loading | Synchronous, at runtime | Resolved ahead of execution |
| Where allowed | Anywhere, conditionally | Top level only (use import() otherwise) |
| Bindings | A copied value | A live reference to the export |
| Tree shaking | Not reliably | Yes |
| Strict mode | Opt-in | Always on |
| File hint | .cjs, or default in Node | .mjs, or "type": "module" |
Live bindings catch people out
An ES import updates when the exporting module changes the value; a CommonJS destructure captures a snapshot:
// counter.js
export let count = 0;
export const inc = () => count++;
// app.js
import { count, inc } from "./counter.js";
inc();
console.log(count); // 1 — with require() destructuring this would print 0
In practice
Write ES modules for anything new: it is the standard, it tree-shakes, and every current runtime supports it. You will still meet CommonJS in older Node packages. Mixing them works in one direction more smoothly than the other — ESM can import a CommonJS package, but CommonJS cannot require() an ESM-only one and must use dynamic import() instead.
