JavaScript Modules: ES Modules, CommonJS, and How They Actually Work (2026)
Import and export seem simple until they're not. Here's what's actually happening under the hood.
The Two Systems
CommonJS (CJS): The old standard (Node.js default until 2022)
→ require() / module.exports
→ Synchronous (blocks execution while loading)
→ Dynamic (can require inside if statements)
→ Node.js .js files default to CJS
ES Modules (ESM): The modern standard
→ import / export
→ Asynchronous (loaded in parallel)
→ Static (must be at top level — compiler can analyze)
→ JavaScript standard (works in browsers too!)
→ Node.js "type": "module" in package.json enables ESM
CommonJS (CJS)
// Exporting
// exports.js
const name = 'my-app';
const version = '1.0.0';
function greet(name) {
return `Hello, ${name}!`;
}
// Named exports (object assignment)
module.exports = {
name,
version,
greet,
};
// Or single export
module.exports = function() {
return 'Hello World';
};
// Or class export
module.exports = class Database {
constructor(url) { this.url = url; }
connect() { /* ... */ }
};
// Importing
// app.js
const { name, version, greet } = require('./exports');
// or: const app = require('./exports');
// Dynamic require (runtime conditional)
let config;
if (process.env.NODE_ENV === 'production') {
config = require('./config/production');
} else {
config = require('./config/development');
}
// Key behavior:
// → require() returns the EXPORTED OBJECT (a copy, not a reference)
// → Each require() caches the module (runs module code only ONCE)
// → module.exports = ... replaces the entire export object
// → exports.foo = ... adds to the export object (doesn't replace)
// → Don't mix! Use module.exports consistently
ES Modules (ESM)
// Exporting
// utils.js
// Named exports (one per item)
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export class Calculator { /* ... */ }
export default class App { /* ... */ } // One default per module
// Named export (list at bottom — cleaner)
const name = 'my-app';
const version = '1.0.0';
function greet(name) { return `Hello, ${name}!`; }
export { name, version, greet };
// Rename on export
export { greet as sayHello, name as appName };
// Re-export from another module
export { add, multiply } from './math.js';
export * from './constants.js'; // Export everything
// Importing
// app.js
// Default import
import App from './App.js';
// Named imports
import { add, PI } from './math.js';
// Rename on import
import { add as sum, PI as MathPi } from './math.js';
// Combine default + named
import React, { useState, useEffect } from 'react';
// Import everything as namespace
import * as math from './math.js';
math.add(2, 3); // namespace access
// Dynamic import (returns Promise — works anywhere!)
const module = await import('./heavy-module.js');
module.doSomething();
// Conditional dynamic import
if (featureEnabled) {
const { premium } = await import('./premium.js');
premium.unlock();
}
// ⚠️ CRITICAL: ESM import paths must include extension in Node.js
import { add } from './math.js'; // ✅ Correct
import { add } from './math'; // ❌ Missing extension (works in bundlers, not Node)
// With package.json "imports" field, you can avoid extensions:
// "imports": {
// "#lib/*": "./src/lib/*.js"
// }
// Then: import { add } from '#lib/math';
Key Differences That Matter
// 1. CJS: exports are COPIES
// ESM: exports are LIVE BINDINGS (references!)
// CJS version:
// counter.js
let count = 0;
function increment() { count++; return count; }
module.exports = { count, increment };
// main.js
const counter = require('./counter');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // STILL 0! (copy, not reference)
// ESM version:
// counter.js
export let count = 0;
export function increment() { count++; return count; }
// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1! (live binding — sees the update!)
// 2. ESM is statically analyzed (compiler knows imports at parse time)
// CJS is dynamic (require() is just a function call)
// This means:
// → Tree-shaking works with ESM (unused exports are eliminated)
// → Bundlers can optimize ESM imports
// → CJS requires must be evaluated at runtime
// 3. Top-level await (ESM only!)
// In an ESM file, you can use await at the top level:
const data = await fetch('/api/config').then(r => r.json());
console.log(data);
// No wrapping in async IIFE needed!
// CJS: Must wrap in async function
(async () => {
const data = await fetch('/api/config').then(r => r.json());
console.log(data);
})();
// 4. __filename and __dirname don't exist in ESM
// CJS:
console.log(__filename); // /path/to/file.js
console.log(__dirname); // /path/to
// ESM equivalent:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Interoperability (Mixing CJS and ESM)
This is where it gets confusing. Here's the rule table:
FROM → TO | CJS require() | ESM import
-------------|-----------------|------------
CJS module | ✅ Direct | ❌ Cannot import CJS with named exports*
ESM module | ❌ Cannot | ✅ Direct
* Exception: ESM can import CJS via default import:
import pkg from './cjs-module.js'; // Works! Gets module.exports
But cannot destructure:
import { foo } from './cjs-module.js'; // ❌ Undefined!
Best practice:
→ If you control the codebase: Pick ONE system and use it everywhere
→ "type": "module" in package.json → all .js files are ESM
→ No "type" or "type": "commonjs" → all .js files are CJS
→ .mjs files are ALWAYS ESM (regardless of package.json)
→ .cjs files are ALWAYS CJS (regardless of package.json)
Practical Patterns
// Pattern 1: Barrel exports (index.js as module entry point)
// src/index.js
export { UserService } from './services/user.js';
export { AuthService } from './services/auth.js';
export { OrderService } from './services/order.js';
// Now consumers can import from the folder:
import { UserService, AuthService } from './src/index.js';
// Pattern 2: Singleton pattern with modules
// database.js (ESM)
let instance = null;
export function getDatabase() {
if (!instance) {
instance = new Database(process.env.DB_URL);
}
return instance;
}
// Module code runs only ONCE, so this naturally creates a singleton
// Pattern 3: Dynamic imports for code splitting
async function loadEditor() {
const { Editor } = await import('./editor.js');
const editor = new Editor('#container');
editor.mount();
}
// Only load the editor when user clicks the button:
document.getElementById('edit-btn').addEventListener('click', loadEditor);
// Pattern 4: Dependency injection with modules
// config.js — can be swapped for testing
export const config = {
apiBase: process.env.API_URL || 'http://localhost:3000',
timeout: parseInt(process.env.TIMEOUT) || 5000,
};
// Pattern 5: Conditional exports (package.json)
{
"exports": {
".": {
"import": "./dist/index.mjs", // ESM consumers
"require": "./dist/index.cjs", // CJS consumers
"types": "./dist/index.d.ts" // TypeScript consumers
},
"./utils": "./dist/utils.js"
}
}
Debugging Import Issues
Common error messages and fixes:
"Cannot use import statement outside a module"
→ Add "type": "module" to package.json
→ Or rename file to .mjs
"require() of ES Module not supported"
→ Use dynamic import: const mod = await import('./mod.mjs')
"Must use import to load ES Module"
→ Change require('./mod') to import mod from './mod.js'
"Module not found" when importing local file
→ Check the file extension (.js is required for Node.js ESM!)
→ Check the path is relative (starts with ./ or ../)
"Cannot find module X" for npm package
→ npm install the package
→ Check if it has "exports" field in its package.json
"Named export 'X' not found" when importing CJS
→ Import the default: import cjsModule from './cjs-file.js'
→ Then access: cjsModule.X
→ This is the biggest gotcha when mixing systems!
CommonJS or ESM — which do you prefer and why?
Follow @armorbreak for more practical developer guides.
Top comments (0)