JavaScript Modules: ES Modules, CommonJS, and How They Actually Work (2026)
Modules are fundamental to modern JavaScript. Understanding how they work saves you from mysterious bugs and build failures.
The Two Module Systems
// === CommonJS (CJS) — Node.js original ===
// Uses require() and module.exports
// Synchronous: loads modules at runtime
// Exporting:
// math.js
function add(a, b) { return a + b; }
function multiply(a, b) { return a * b; }
module.exports = { add, multiply };
// OR: exports.add = add;
// OR: module.exports = add; (single export)
// Importing:
const { add, multiply } = require('./math');
const utils = require('./utils');
// === ES Modules (ESM) — Modern standard ===
// Uses import/export
// Static: analyzed at compile time (tree shaking!)
// Exporting:
// math.mjs (or package.json "type": "module")
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
// Named export (multiple per file):
export const PI = 3.14159;
// Default export (one per file):
export default class Calculator { ... }
// Re-exporting from another module:
export { add, multiply } from './math.js';
export * from './utils.js'; // Re-export everything
// Importing:
import { add, multiply } from './math.js';
import Calculator from './Calculator.js';
import * as math from './math.js'; // Namespace import
// Dynamic import (works in both CJS and ESM!):
const heavyModule = await('./heavy-module.js');
heavyModule.doSomething();
Key Differences That Matter
┌───────────────────────┬─────────────────────┬──────────────────────┐
│ Feature │ CommonJS │ ES Modules │
├───────────────────────┼─────────────────────┼──────────────────────┤
│ Syntax │ require() / exports │ import / export │
│ Loading │ Synchronous │ Asynchronous │
│ this keyword │ {} (empty object) │ undefined │
│ Top-level await │ ❌ No │ ✅ Yes │
│ Tree shaking │ ❌ No │ ✅ Yes (static analysis)│
│ Cyclic dependencies │ Partially works │ Works with live bindings│
│ __dirname, __filename│ Available ❌ Not available │
│ require() │ Can use anywhere │ Only top level │
│ Dynamic import() │ Not needed │ import() for lazy load│
│ this in modules │ Points to module.exports | Points to undefined│
│ Node.js support │ Always supported │ Requires .mjs or "type":"module"│
└───────────────────────┴─────────────────────┴──────────────────────┘
Interop: Mixing CJS and ESM
// Importing CommonJS in ESM:
import { createServer } from 'http'; // CJS module imported into ESM
// Works! Node.js provides interop.
// Importing ESM in CommonJS:
// ❌ This does NOT work:
const { foo } = require('./esm-module.mjs'); // Error!
// ✅ Use dynamic import instead:
async function main() {
const { foo } = await import('./esm-module.mjs');
foo();
}
main();
// Package.json configuration:
{
"name": "my-package",
"type": "module", // Treats all .js files as ESM!
// OR use explicit extensions:
// "main": "./index.cjs", // For CJS consumers
// "exports": {
// ".": {
// "import": "./index.mjs", // For ESM consumers
// "require": "./index.cjs" // For CJS consumers
// }
// },
"exports": {
".": "./index.js", // Conditional exports based on consumer type
"./utils": "./utils.js"
}
}
Module Resolution: How Node Finds Your Files
// When you write: import './utils'
// Node looks for (in order):
// 1. ./utils.mjs (if type: "module")
// 2. ./utils.js
// 3. ./utils/index.mjs
// 4. ./utils/index.js
// 5. ./utils.json (with --experimental-json-modules)
// When you write: import 'lodash'
// Node looks for (in order):
// 1. node_modules/lodash/package.json → "main" or "exports" field
// 2. node_modules/lodash/index.js
// 3. node_modules/lodash/index.cjs
// 4. Walk up parent directories looking for node_modules/
// ⚠️ Common pitfalls:
// Missing extension:
import './utils'; // Might fail without .js extension in strict ESM!
import './utils.js'; // Explicit is safer
// Directory imports:
import './components'; // Looks for components/index.js
// NOT components.js (common mistake!)
// File case sensitivity:
import './MyComponent'; // Different from mycomponent on Linux!
// On macOS they're the same (case-insensitive), on Linux they're not.
// This causes "works on my machine" bugs!
// Bare specifiers (no relative path):
import 'react'; // Resolved via node_modules (or package.json imports)
import '#config'; // Requires package.json "imports" mapping:
// "imports": { "#config": "./config.js" }
Practical Patterns
// Pattern 1: Barrel files (re-exports)
// components/index.js
export { Button } from './Button.jsx';
export { Input } from './Input.jsx';
export { Modal } from './Modal.jsx';
export { Card } from './Card.jsx';
// Now you can:
import { Button, Card } from './components'; // Clean imports!
// Pattern 2: Lazy loading (reduce initial bundle size)
// Instead of:
import HeavyChart from './HeavyChart'; // Loaded immediately (even if not used!)
// Use:
const Chart = React.lazy(() => import('./HeavyChart'));
// Only fetched when first rendered!
// In your component:
<Suspense fallback={<Spinner />}>
<Chart data={data} />
</Suspense>
// Pattern 3: Dynamic imports by condition
if (process.env.NODE_ENV === 'development') {
const DevTools = await('./DevTools');
DevTools.init();
}
// Pattern 4: Module mock pattern (for testing)
// jest.config.js or inline:
jest.mock('./api', () => ({
fetchUsers: jest.fn().mockResolvedValue(mockUsers),
createUser: jest.fn().mockResolvedValue(newUser),
}));
// Pattern 5: Singleton pattern with modules
// config.js (ESM)
let config = null;
export function getConfig() {
if (!config) {
config = JSON.parse(readFileSync('/etc/app/config.json', 'utf-8'));
}
return config;
}
// First import reads file, subsequent calls return cached value
// (Module is only evaluated once!)
// Pattern 6: Circular dependency handling
// A.js imports B.js, B.js imports A.js
// ❌ Problematic (CJS gets partial export, ESM works better):
// A.js
export function functionFromA() { ... }
import { functionFromB } from './B.js';
// B.js
export function functionFromB() { ... }
import { functionFromA } from './A.js';
// ESM handles this correctly due to live bindings!
// CJS: B.js might get an empty object for A's exports
// Fix for CJS: defer the usage of circular imports inside functions
Debugging Module Issues
# "Module not found" errors:
# Check: Is the path correct? Case sensitive? Extension included?
# "Cannot use import outside a module":
# Fix: Add "type": "module" to package.json
# OR rename file to .mjs
# OR use dynamic import()
# "require is not defined":
# You're using ESM syntax but file is treated as CJS
# Fix: Add "type": "module" to package.json
# "Cannot find module X":
# npm install X # Is it installed?
# ls node_modules/X # Does it exist?
# cat node_modules/X/package.json # Does it have correct main/exports?
# Check what Node resolves:
node -e "console.log(require.resolve('express'))"
# Or for ESM:
node --input-type=module -e "import.meta.resolve('express').then(console.log)"
# Clear cache (sometimes stale cache causes issues):
rm -rf node_modules/.cache
# Or restart Node process
# Check if file is CJS or ESM:
head -1 myfile.js
# If it has "use strict"; or require() → likely CJS
# If it has import/export → must be ESM
Which module system do you prefer? Have you hit any weird interop issues?
Follow @armorbreak for more practical developer guides.
Top comments (0)