CommonJS vs ESM
Node.js supports two module systems: CommonJS and ECMAScript Modules (ESM). CommonJS is the original system based on require() and module.exports. ESM is the standardized system used by browsers and modern tooling, based on import and export statements.
Understanding both systems is non-negotiable for Node.js developers. Many legacy packages are CommonJS, while modern frameworks, tree-shaking, and browser compatibility require ESM. Hybrid projects are common, and interop issues cause frequent build and runtime errors.
This guide compares syntax, loading behavior, package.json fields, conditional exports, dynamic imports, top-level await, and practical migration strategies.
CommonJS modules are loaded synchronously at runtime. The require() function reads the file, wraps it in a function, executes it, and returns module.exports. This makes CommonJS flexible but prevents static analysis and tree-shaking.
| 1 | // utils.cjs |
| 2 | function greet(name) { |
| 3 | return `Hello, ${name}!`; |
| 4 | } |
| 5 | |
| 6 | module.exports = { greet }; |
| 7 | |
| 8 | // Alternative syntax |
| 9 | exports.greet = greet; |
| 1 | // app.cjs |
| 2 | const { greet } = require('./utils.cjs'); |
| 3 | |
| 4 | console.log(greet('World')); |
| 5 | |
| 6 | // Dynamic require based on condition |
| 7 | const env = process.env.NODE_ENV || 'development'; |
| 8 | const config = require(`./config/${env}.cjs`); |
CommonJS supports dynamic requires, cyclic dependencies, and running in a wrapper function where __dirname and __filename are available. It remains the default module format when no type field is set in package.json.
note
ESM uses static import and export statements. The module graph is parsed before execution, enabling tree-shaking, top-level await, and better static analysis. Node.js treats .mjs files or files inside a package with "type": "module" as ESM.
| 1 | // utils.mjs |
| 2 | export function greet(name) { |
| 3 | return `Hello, ${name}!`; |
| 4 | } |
| 5 | |
| 6 | export const VERSION = '1.0.0'; |
| 7 | |
| 8 | // Default export |
| 9 | export default function main() { |
| 10 | console.log('running main'); |
| 11 | } |
| 1 | // app.mjs |
| 2 | import { greet, VERSION } from './utils.mjs'; |
| 3 | import main from './utils.mjs'; |
| 4 | |
| 5 | console.log(greet('World')); |
| 6 | console.log(VERSION); |
| 7 | main(); |
ESM files run in strict mode automatically and do not provide require, __dirname, or __filename. Use import.meta.url, import.meta.dirname (Node 20.11+), and import.meta.filename instead.
warning
Several package.json fields control module resolution. Setting them correctly is essential for packages consumed by both CommonJS and ESM projects.
| Field | Purpose | Example |
|---|---|---|
| type | Default module system | "module" or "commonjs" |
| main | Entry point (legacy) | "./index.js" |
| module | ESM entry (bundler hint) | "./index.mjs" |
| exports | Conditional entry points | See below |
| imports | Package self-references | "#lib/*": "./lib/*" |
| 1 | { |
| 2 | "name": "my-package", |
| 3 | "version": "1.0.0", |
| 4 | "type": "module", |
| 5 | "main": "./dist/index.cjs", |
| 6 | "module": "./dist/index.mjs", |
| 7 | "exports": { |
| 8 | ".": { |
| 9 | "import": "./dist/index.mjs", |
| 10 | "require": "./dist/index.cjs" |
| 11 | }, |
| 12 | "./package.json": "./package.json" |
| 13 | } |
| 14 | } |
best practice
Interoperability works differently depending on which system imports which. An ESM module can import a CommonJS module using a default import or named import, but the named imports are synthesized from module.exports via static analysis.
| 1 | // cjs-module.cjs |
| 2 | module.exports = { |
| 3 | foo: 'bar', |
| 4 | baz: 42 |
| 5 | }; |
| 6 | |
| 7 | // esm-consumer.mjs |
| 8 | import cjs from './cjs-module.cjs'; |
| 9 | import { foo, baz } from './cjs-module.cjs'; |
| 10 | |
| 11 | console.log(cjs.foo); // bar |
| 12 | console.log(foo); // bar (synthesized) |
A CommonJS module cannot use import directly, but it can dynamically import an ESM module. The dynamic import returns a promise that resolves to the module namespace object.
| 1 | // cjs-consumer.cjs |
| 2 | async function loadEsm() { |
| 3 | const { default: esmDefault, named } = await import('./esm-module.mjs'); |
| 4 | esmDefault(); |
| 5 | console.log(named); |
| 6 | } |
| 7 | |
| 8 | loadEsm(); |
warning
ESM supports await at the top level of a module. This simplifies initialization code but blocks execution of the importing module until the awaited promise settles. Use it sparingly for configuration and connection setup.
| 1 | // db.mjs |
| 2 | import { createConnection } from 'mysql2/promise'; |
| 3 | |
| 4 | export const db = await createConnection({ |
| 5 | host: process.env.DB_HOST, |
| 6 | user: process.env.DB_USER, |
| 7 | password: process.env.DB_PASSWORD, |
| 8 | database: process.env.DB_NAME |
| 9 | }); |
| 10 | |
| 11 | // app.mjs |
| 12 | import { db } from './db.mjs'; |
| 13 | |
| 14 | const [rows] = await db.execute('SELECT 1'); |
| 15 | console.log(rows); |
Top-level await delays not just the module that contains it, but every module that imports it. This can create cascading startup delays. For optional initialization, expose an async init() function instead.
info
import() is available in both CommonJS and ESM. It loads a module asynchronously and returns a promise. This is useful for code splitting, lazy loading, and loading ESM from CommonJS.
| 1 | // Lazy load a heavy dependency only when needed |
| 2 | async function generateReport(type) { |
| 3 | if (type === 'pdf') { |
| 4 | const { default: puppeteer } = await import('puppeteer'); |
| 5 | // ... generate PDF |
| 6 | } |
| 7 | |
| 8 | if (type === 'csv') { |
| 9 | const { stringify } = await import('csv-stringify/sync'); |
| 10 | // ... generate CSV |
| 11 | } |
| 12 | } |
best practice
Migrating a large CommonJS codebase to ESM should be incremental. Start with leaf modules, add .mjs entry points, and use conditional exports to maintain CommonJS compatibility during the transition.
Step 1: Add type field
Rename existing .js files to .cjs, then set "type": "module". New files can use .js for ESM.
Step 2: Update import paths
Add file extensions to all relative imports. Use directory indexes or exports aliases to avoid brittle paths.
Step 3: Replace __dirname
Use import.meta.dirname on Node.js 20.11+ or derive it from import.meta.url on older versions.
Step 4: Test interop
Verify that CommonJS consumers can still require your package and that ESM consumers get the expected named exports.
note
| Mistake | Why It Fails | Fix |
|---|---|---|
| require in ESM | require is not defined | Use createRequire(import.meta.url) |
| Missing file extension | Cannot find module | Add .js, .mjs, or .cjs |
| exports = | Breaks CommonJS export | Use module.exports |
| Cyclic dependencies | Partial exports at runtime | Refactor to break the cycle |