---
title: "15 — Modules — CommonJS vs ESModules, and Why It Matters"
uid: javascript-modules
tags: ["esmodules", "require", "roadmap:javascript", "export", "import", "modules", "commonjs", "javascript"]
excerpt: "Two module systems — CommonJS (synchronous require) and ESModules (static import) — don't interoperate cleanly, which is the entire reason bundlers and .mjs/.cjs exist."
date: 2026-08-13T03:28:04+0000
source: https://www.aveshina.my.id/en/blog/javascript-modules
---

"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.

```figure
<svg viewBox="0 0 740 280" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Two module systems side by side. Left: CommonJS — require() and module.exports, synchronous at runtime, Node's original. Right: ESModules — import and export, statically analyzed at parse time, the ES6 standard, works in browsers and Node. A clash in the middle: the two don't interoperate cleanly.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <!-- CommonJS -->
    <rect x="30" y="30" width="310" height="220" rx="10" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="185" y="55" font-size="13" font-weight="700" fill="#1e1b4b" text-anchor="middle">CommonJS</text>
    <g font-size="10" font-family="ui-monospace,monospace" fill="#1e1b4b">
      <text x="50" y="90">const fs = require("fs");</text>
      <text x="50" y="112">module.exports = { add };</text>
    </g>
    <text x="185" y="150" font-size="10" fill="#475569" text-anchor="middle">synchronous · runtime</text>
    <text x="185" y="168" font-size="10" fill="#475569" text-anchor="middle">Node's original system</text>
    <text x="185" y="200" font-size="10" font-style="italic" fill="#64748b" text-anchor="middle">.cjs extension</text>
    <text x="185" y="232" font-size="9" fill="#64748b" text-anchor="middle">dynamic — you can require()</text>
    <text x="185" y="246" font-size="9" fill="#64748b" text-anchor="middle">inside an if statement</text>

    <!-- ESM -->
    <rect x="400" y="30" width="310" height="220" rx="10" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="555" y="55" font-size="13" font-weight="700" fill="#052e16" text-anchor="middle">ESModules (ESM)</text>
    <g font-size="10" font-family="ui-monospace,monospace" fill="#052e16">
      <text x="420" y="90">import fs from "fs";</text>
      <text x="420" y="112">export function add() {}</text>
      <text x="420" y="134">export default { add };</text>
    </g>
    <text x="555" y="168" font-size="10" fill="#475569" text-anchor="middle">static · parse-time</text>
    <text x="555" y="186" font-size="10" fill="#475569" text-anchor="middle">the standard · browser + Node</text>
    <text x="555" y="218" font-size="10" font-style="italic" fill="#64748b" text-anchor="middle">.mjs extension (or type: module)</text>

    <!-- clash -->
    <text x="370" y="150" font-size="22" fill="#dc2626" text-anchor="middle">⚡</text>
    <text x="370" y="175" font-size="9" font-style="italic" fill="#dc2626" text-anchor="middle">don't mix</text>
  </g>
</svg>
```

## 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](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/](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](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](https://nodejs.org/api/esm.html)

[5] I. Kantor, "Export and Import," The Modern JavaScript Tutorial, 2024. [Online]. Available: [https://javascript.info/import-export](https://javascript.info/import-export)

```quiz
Q: What's the key structural difference between CommonJS and ESModules?
- CommonJS is dynamic and synchronous (require is a function, runs at runtime); ESM is static and resolved at parse time (imports at top level)
- They are identical; ESM just added shorter syntax
correct: 0
explain: require/module.exports is dynamic — you can conditionally require. ESM import/export is static — the dependency graph is fully known at parse time, which enables tree-shaking and editor tooling.

Q: Why does ESM enable tree-shaking but CJS doesn't?
- ESM imports are static top-level declarations, so the bundler knows the full graph at build time and can drop unused exports
- ESM files are smaller
correct: 0
explain: Because ESM can't conditionally import, a bundler can prove an export is never used and remove it. CJS require() is dynamic, so anything might be loaded at runtime — the bundler can't safely remove it.

Q: In Node, how do you force a file to be treated as ESM regardless of project settings?
- Rename it to .mjs (or set "type": "module" in package.json for .js files)
- Add "use esm" at the top
correct: 0
explain: Node uses the file extension (.mjs = ESM, .cjs = CJS) and the package.json "type" field (.js files follow the type). There's no "use esm" directive — that's a confusion with "use strict".

Q: You're in an ESM project and need to use a CJS-only dependency. What works?
- import pkg from "cjs-lib" (the whole module.exports becomes the default import)
- const x = require("cjs-lib")
correct: 0
explain: require isn't defined in ESM. A CJS module's module.exports appears as the default export, so default-import works. Named imports from CJS are best-effort and sometimes break; for those, use createRequire or a dynamic import().

Q: When would you use a dynamic import() in ESM?
- When you need to conditionally or lazily load a module at runtime (code-splitting, on-demand features)
- You should never use it; static imports are always required
correct: 0
explain: Static imports are the default and must be top-level. import() returns a promise and can be used anywhere — inside conditions, on user interaction — enabling lazy loading and code-splitting that static imports can't do.
```
