JavaScript Modules: ES Modules, CommonJS, and How They Actually Work (2026)
Modules are the foundation of modern JavaScript. Understanding how they work will save you hours of debugging. Here's the complete guide.
The Two Module Systems
// === CommonJS (CJS) — Node.js default (.cjs or no extension in Node)
// Exporting:
module.exports = { name: 'utils', add: (a, b) => a + b };
// Or individual exports:
exports.name = 'utils';
exports.add = (a, b) => a + b;
// Importing:
const utils = require('./utils');
const { add } = require('./math');
const pkg = require('lodash');
// Key behavior:
// - Synchronous (blocks execution until loaded)
// - Resolved at runtime
// - Can use conditionally: if (condition) { const x = require('x'); }
// - __dirname and __filename are available
// === ES Modules (ESM) — Modern standard (.mjs or "type": "module" in package.json)
// Named exports (recommended):
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export class Calculator { ... }
// Default export (one per module):
export default class AppConfig { ... }
// Importing:
import { add, PI } from './math.js'; // Named imports
import AppConfig from './config.js'; // Default import
import * as utils from './utils.js'; // Namespace import (all named exports)
import './side-effects.js'; // Side-effect only import
// Dynamic imports (works everywhere!):
const module = await import('./heavy-module.js');
module.doSomething();
// Key behavior:
// - Asynchronous (can be tree-shaken by bundlers)
// - Static structure (analyzable at build time)
// - Top-level await is supported!
// - No __dirname/__filename (use alternatives below)
// Getting __dirname equivalent in ESM:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
How Imports Actually Work Behind the Scenes
// CommonJS: Runtime copying
// When you require() something, Node.js:
// 1. Reads the file
// 2. Wraps it in a function: (function(exports, require, module, __filename, __dirname) { ... })
// 3. Executes it
// 4. Returns module.exports
// This means: you get a COPY of primitive values!
// counter.js
let count = 0;
function increment() { return ++count; }
module.exports = { count, increment };
// main.js
const counter = require('./counter');
counter.increment(); // count = 1
console.log(counter.count); // Still 0! (primitive was copied at require time!)
// Fix: Use a getter function or export an object reference
module.exports = { getCount: () => count, increment };
// ES Modules: Live bindings
// When you import from ESM, you get a LIVE REFERENCE to the original value!
// counter.mjs
export let count = 0;
export function increment() { return ++count; }
// main.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1! (live binding — always reads current value!)
Circular Dependencies: The Hidden Bug Factory
// ❌ Circular dependency with CommonJS (subtle bugs!):
// a.js
const b = require('./b');
console.log('in a.js, b.value =', b.value); // undefined!
module.exports = { value: 'from A' };
// b.js
const a = require('./a');
console.log('in b.js, a.value =', a.value); // {} (empty object!)
module.exports = { value: 'from B' };
// main.js
const a = require('./a');
// Output:
// in b.js, a.value = {} ← a's exports not yet complete when b loads!
// in a.js, b.value = undefined ← b's exports not yet complete when a loads!
// ✅ Solutions:
// Solution 1: Delay access to circular deps (use functions):
// a.js
let b; // Declare but don't require yet
function getB() { if (!b) b = require('./b'); return b; }
function doSomething() {
console.log(getB().value); // Access only when actually needed (after init completes)
}
module.exports = { value: 'from A', doSomething };
// Solution 2: Use ES Modules (better handling of circular deps):
// a.mjs
export let value = 'from A';
import { value as bValue, init as initB } from './b.mjs';
// b.mjs
export let value = 'from B';
import { value as aValue } from './a.mjs';
console.log(aValue); // Works correctly with live bindings after initialization
// Solution 3: Extract shared code to third module (best architecture):
// shared.js (no circular deps here!)
module.exports = { sharedData: {}, sharedFn() {} };
// Both a.js and b.js require shared.js instead of each other
Practical Migration Guide
// package.json — enabling ESM
{
"type": "module", // Makes .js files ESM by default!
"exports": { // Explicit entry points (good practice)
".": "./src/index.js",
"./utils": "./src/utils.js"
},
"main": "./src/index.js" // For CJS consumers (if dual publishing)
}
// Mixed CJS/ESM project patterns:
// Pattern 1: Keep CJS for Node internals, ESM for app code
// package.json has NO "type" field → .js = CJS, .mjs = ESM
// src/app.mjs → ESM (your app code)
// lib/internal.cjs → CJS (Node-compatible utilities)
// Pattern 2: Full ESM migration checklist:
// ✅ Change all require() to import
// ✅ Change all module.exports to export
// ✅ Add .js extensions to all relative imports (required in ESM!)
// ✅ Replace __dirname / __filename with import.meta equivalents
// ✅ Replace process.env with explicit imports where needed
// ✅ Remove any top-level conditional requires
// ⚠️ Note: You can't import() from a CJS module that uses dynamic requires
// ⚠️ Note: Some npm packages still don't support ESM properly
// Pattern 3: Dual package (support both CJS and ESM consumers)
// package.json
{
"name": "my-lib",
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.mjs",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs"
}
},
"types": "./dist/types/index.d.ts"
}
Package Management & Module Resolution
# Where does Node look for modules?
# 1. Built-in modules (fs, path, http, etc.) — no install needed
# 2. node_modules/ (local to project)
# 3. Parent directory node_modules/ (walks up to root)
# 4. Global node_modules (npm install -g)
# 5. NODE_PATH environment variable
# Useful commands:
npm ls --depth=0 # Top-level dependencies
npm why <package> # Why is this package installed?
npm outdated # Check for updates
npm audit # Security vulnerabilities
du -sh node_modules # Total size of dependencies
npx depcheck # Find unused dependencies
# Resolution order for import 'foo':
# 1. Check if it's a built-in (Node.js core)
# 2. Check node_modules/foo/package.json → "main" or "exports" field
# 3. Check node_modules/foo/index.js
# 4. Repeat in parent directories
# For relative imports: import './utils'
# 1. Resolve against current file's directory
# 2. Append .js, then /index.js, then check package.json "exports"
Which module system do you prefer? Ever been bitten by a circular dependency?
Follow @armorbreak for more practical developer guides.
Top comments (0)