JavaScript Modules: ES Modules, CommonJS, and How They Actually Work (2026)
Modules are the foundation of every modern JavaScript project. Understanding how they work will save you hours of debugging.
The Two Module Systems
// === CommonJS (CJS) — Node.js original ===
// Uses require() and module.exports
// Synchronous: loads at runtime
// Exporting:
// myModule.js
const name = "My Module";
const data = [1, 2, 3];
function greet(who) {
return `Hello, ${who} from ${name}!`;
}
// Named exports (object):
module.exports = { name, data, greet };
// Or single export:
module.exports = class UserService {
// ...
};
// Importing:
const { greet, data } = require('./myModule');
const UserService = require('./UserService');
// Destructuring is optional — can import the whole object:
const myModule = require('./myModule');
myModule.greet("Alice");
// === ES Modules (ESM) — Modern standard ===
// Uses import/export
// Static: analyzed at compile time, supports tree-shaking
// Exporting:
// myModule.mjs (or .js with "type": "module" in package.json)
export const name = "My Module";
export const data = [1, 2, 3];
export function greet(who) {
return `Hello, ${who} from ${name}!`;
}
// Default export (one per file):
export default class UserService {
// ...
}
// Re-exporting (barrel files):
export { greet } from './greet.js';
export { fetchUser } from './api.js';
export { default as Utils } from './utils.js';
// Importing:
import { greet, data } from './myModule.mjs';
import UserService from './UserService.mjs'; // Default import
import * as MyModule from './myModule.mjs'; // Namespace import (everything)
// Dynamic import (works in both CJS and ESM!):
const heavyModule = await import('./heavy-module.mjs');
heavyModule.doSomething();
How Each System Actually Works
CommonJS Execution Model:
┌─────────────────────────────┐
│ require('./foo') called │
│ ↓ │
│ Read and PARSE foo.js │ ← Happens at runtime
│ Wrap in function wrapper: │
│ (function(exports, require,│
│ module, __filename, │
│ __dirname) { ... })() │
│ ↓ │
│ EXECUTE code in foo.js │ ← Runs synchronously
│ ↓ │
│ Return module.exports │
└─────────────────────────────┘
Key insight: Every CJS file gets wrapped in a function.
That's why `require`, `exports`, `module` are available — they're arguments!
ES Module Execution Model:
┌─────────────────────────────┐
│ import { x } from './foo' │
│ ↓ │
│ Parse and ANALYZE imports │ ← Compile time (static)
│ Build dependency graph │
│ ↓ │
│ Download all dependencies │ ← Can be async!
│ ↓ │
│ INSTANTIATE each module │ ← Link exports to imports
│ ↓ │
│ EVALUATE code in order │ ← Depth-first, post-order
└─────────────────────────────┘
Key insight: ESM has three distinct phases:
1. Construction (find & link)
2. Evaluation (execute top-level code)
3. This happens ONCE per module (cached after first load!)
Interoperability (Mixing CJS and ESM)
// === Importing CJS in ESM ===
// package.json has no "type": "module" → treated as CJS
// In an ESM file (.mjs or "type": "module"):
import pkg from './cjs-module.cjs'; // Works! default = module.exports
import { namedExport } from './cjs-module.cjs'; // ⚠️ Only works if CJS used named exports!
// ⚠️ Gotcha: CJS module.exports = { a: 1 }
// import { a } from './cjs.cjs' → UNDEFINED!
// Because CJS doesn't have real named exports.
// Workaround: use namespace import + destructure:
import cjs from './cjs-module.cjs';
const { a } = cjs; // Now it works!
// === Importing ESM in CJS ===
// This does NOT work directly:
// const esm = require('./esm-module.mjs'); // ❌ Error!
// Options:
// 1. Dynamic import (returns Promise):
async function loadEsm() {
const mod = await import('./esm-module.mjs'); // Works in CJS too!
mod.default.doSomething();
}
// 2. Use .cjs extension for CommonJS files:
// utils.cjs → always CJS, regardless of package.json "type"
// utils.mjs → always ESM, regardless of package.json "type"
// === Best Practice: Decide ONE system per project ===
// New projects: ESM ("type": "module" in package.json)
// Legacy projects: CJS (no "type" field, or "type": "commonjs")
// Libraries: Consider dual publish (both CJS and ESM support)
Practical Patterns
// === Pattern 1: Barrel/Index Files ===
// components/index.js (ESM):
export { Button } from './Button.jsx';
export { Input } from './Input.jsx';
export { Modal } from './Modal.jsx';
// Usage:
import { Button, Input, Modal } from './components';
// Clean imports! One line instead of three.
// === Pattern 2: Lazy Loading (Performance!) ===
// Don't import heavy modules until needed:
// ❌ Eager (loads everything at startup):
import HeavyChartLibrary from './chart-library.js';
import PDFGenerator from './pdf-generator.js';
function renderChart(data) {
HeavyChartLibrary.render(data);
}
function generatePDF(content) {
PDFGenerator.create(content);
}
// ✅ Lazy (only loads when function is called):
async function renderChart(data) {
const { default: Chart } = await import('./chart-library.js');
Chart.render(data);
}
async function generatePDF(content) {
const { default: PDF } = await import('./pdf-generator.js');
PDF.create(content);
}
// Great for route-based splitting (React, Vue, etc.):
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
// === Pattern 3: Conditional Imports ===
// Load different modules based on environment:
let database;
if (process.env.NODE_ENV === 'test') {
database = await import('./database/mock.js');
} else if (process.env.DB_TYPE === 'postgres') {
database = await import('./database/postgres.js');
} else {
database = await import('./database/sqlite.js');
}
// === Pattern 4: Circular Dependencies (Avoid Them!) ===
// A.js imports B.js, B.js imports A.js → circular!
// Why it's bad:
// A.js: const b = require('./B'); console.log(b.value); // undefined?!
// B.js: const a = require('./A'); a.value = 10;
// B gets A's module.exports BEFORE A finishes executing.
// So when B runs, A hasn't set value yet.
// Solutions:
// 1. Restructure (best): Move shared code into C.js that both A and B import
// 2. Delay access: Put the circular-requiring code inside a function (not top-level)
// 3. Dependency injection: Pass the dependency as argument instead of importing
// === Pattern 5: Mocking Modules for Testing ===
// test/setup.js — mock external services:
jest.mock('../services/api', () => ({
fetchData: jest.fn().mockResolvedValue({ items: [] }),
postUpdate: jest.fn(),
}));
// test/UserService.test.js:
import { userService } from '../services/userService.js';
import { api } from '../services/api.js';
test('fetches user data', async () => {
api.fetchData.mockResolvedValueOnce({ id: 1, name: 'Test' });
const user = await userService.getUser(1);
expect(user.name).toBe('Test');
});
package.json Configuration
{
"name": "my-project",
"version": "1.0.0",
// THIS FIELD CHANGES EVERYTHING:
"type": "module",
// "type": "module" → All .js files are ESM (use import/export)
// No "type" field / "type": "commonjs" → All .js files are CJS (use require/exports)
// File extensions override the "type" setting:
// .cjs → Always CommonJS (even with "type": "module")
// .mjs → Always ESM (even without "type": "module")
"main": "./dist/index.cjs", // CJS entry point
"module": "./dist/index.mjs", // ESM entry point
"types": "./dist/index.d.ts", // TypeScript types
"exports": {
".": {
"import": "./dist/index.mjs", // ESM consumers
"require": "./dist/index.cjs", // CJS consumers
"types": "./dist/index.d.ts" // TypeScript
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
},
// For tools (bundler, test runner):
"sideEffects": false, // Enables tree-shaking!
"engines": {
"node": ">=18.0.0" // Requires native ESM support
}
}
Which module system do you prefer? Ever been bitten by a CJS/ESM interop issue?
Follow @armorbreak for more practical developer guides.
Top comments (0)