|$ curl https://forge-ai.dev/api/markdown?path=docs/nodejs/modules
$cat docs/commonjs-vs-esm.md
updated Recently·24 min read·published

CommonJS vs ESM

Node.jsModulesIntermediate🎯Free Tools
Introduction

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

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.

utils.cjs
JavaScript
1// utils.cjs
2function greet(name) {
3 return `Hello, ${name}!`;
4}
5
6module.exports = { greet };
7
8// Alternative syntax
9exports.greet = greet;
app.cjs
JavaScript
1// app.cjs
2const { greet } = require('./utils.cjs');
3
4console.log(greet('World'));
5
6// Dynamic require based on condition
7const env = process.env.NODE_ENV || 'development';
8const 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

In CommonJS, assigning exports = { ... } does not re-export the module. Always use module.exports for reassignment.
ECMAScript Modules (ESM)

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.

utils.mjs
JavaScript
1// utils.mjs
2export function greet(name) {
3 return `Hello, ${name}!`;
4}
5
6export const VERSION = '1.0.0';
7
8// Default export
9export default function main() {
10 console.log('running main');
11}
app.mjs
JavaScript
1// app.mjs
2import { greet, VERSION } from './utils.mjs';
3import main from './utils.mjs';
4
5console.log(greet('World'));
6console.log(VERSION);
7main();

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

Import paths in ESM must include the file extension or be bare specifiers resolved by package.json. import { foo } from './utils' fails unless you use a loader or TypeScript path alias.
package.json Fields

Several package.json fields control module resolution. Setting them correctly is essential for packages consumed by both CommonJS and ESM projects.

FieldPurposeExample
typeDefault module system"module" or "commonjs"
mainEntry point (legacy)"./index.js"
moduleESM entry (bundler hint)"./index.mjs"
exportsConditional entry pointsSee below
importsPackage self-references"#lib/*": "./lib/*"
package.json
JSON
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

Always use exports for new packages. It restricts which files are importable, prevents consumers from reaching into internal modules, and supports dual-mode CommonJS/ESM publishing.
Interop Between CommonJS and ESM

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.

interop-esm-imports-cjs.mjs
JavaScript
1// cjs-module.cjs
2module.exports = {
3 foo: 'bar',
4 baz: 42
5};
6
7// esm-consumer.mjs
8import cjs from './cjs-module.cjs';
9import { foo, baz } from './cjs-module.cjs';
10
11console.log(cjs.foo); // bar
12console.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.

interop-cjs-imports-esm.cjs
JavaScript
1// cjs-consumer.cjs
2async function loadEsm() {
3 const { default: esmDefault, named } = await import('./esm-module.mjs');
4 esmDefault();
5 console.log(named);
6}
7
8loadEsm();

warning

Synthesized named exports from CommonJS can break if the package changes its export shape. Prefer default imports when consuming CommonJS from ESM unless the package explicitly documents named exports.
Top-Level Await

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.

top-level-await.mjs
JavaScript
1// db.mjs
2import { createConnection } from 'mysql2/promise';
3
4export 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
12import { db } from './db.mjs';
13
14const [rows] = await db.execute('SELECT 1');
15console.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

Avoid top-level await for network calls that are not required during module load. Prefer explicit initialization functions that the application calls after startup.
Dynamic Import

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.

dynamic-import.mjs
JavaScript
1// Lazy load a heavy dependency only when needed
2async 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

Wrap dynamic imports in try/catch. A missing module or syntax error throws asynchronously, and unhandled rejections can crash the process.
Migration Strategies

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

Tools like esbuild, tsup, and unbuild can emit both CommonJS and ESM artifacts from a single TypeScript source, making dual-mode packages straightforward.
Common Mistakes
MistakeWhy It FailsFix
require in ESMrequire is not definedUse createRequire(import.meta.url)
Missing file extensionCannot find moduleAdd .js, .mjs, or .cjs
exports = Breaks CommonJS exportUse module.exports
Cyclic dependenciesPartial exports at runtimeRefactor to break the cycle
$Blueprint — Engineering Documentation·Section ID: NODE-02·Revision: 1.0