cheat sheet
JavaScript Modules
CommonJS vs ES Modules in Node.js — syntax, how Node chooses the module system, dynamic import, top-level await, CJS/ESM interop, and common pitfalls.
JavaScript Modules
What it is
JavaScript has two module systems running in parallel:
- CommonJS (CJS) — Node.js's original module system (
require()/module.exports), introduced in 2009. Files are evaluated synchronously. Still the default in Node.js unless overridden. - ES Modules (ESM) — The browser-native, TC39-standardised system (
import/export), added to Node.js in v12 (stable in v14). Files are parsed statically and support top-levelawait.
Both work in Node.js today. The ecosystem is still migrating; understanding the interop rules saves hours of debugging.
ESM syntax
Named exports and imports
A module can export any number of named bindings with export; consumers import exactly the names they need with import { }. Named exports are the preferred pattern for utilities and multi-function modules because they are statically analysable (tree-shakeable).
// math.mjs — named exports
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export const PI = 3.14159;
// main.mjs — named imports
import { add, PI } from './math.mjs';
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
Default export and import
Each module may have at most one default export, typically used when the module represents a single class, function, or value. The importer can choose any local name — there is no enforcement of a canonical name.
// greet.mjs
export default function greet(name) {
return `Hello, ${name}!`;
}
// main.mjs
import greet from './greet.mjs'; // default import; any name works
import myGreet from './greet.mjs'; // same thing, different local name
console.log(greet('World')); // Hello, World!
Rename on import / export
import { add as sum, subtract as minus } from './math.mjs';
export { add as addNumbers };
Namespace import (import everything)
import * as ns collects all named exports from a module into a single namespace object. Use it when you need access to many exports under a common prefix, or when re-exporting an entire module under a new name.
import * as Math from './math.mjs';
console.log(Math.add(1, 2)); // 3
console.log(Math.PI); // 3.14159
Re-exports
Re-export syntax lets a barrel file (typically index.mjs) aggregate and re-publish exports from several sub-modules without importing them into local scope first. export * from re-exports all named exports; export { name } from re-exports specific ones.
// index.mjs — barrel file that re-exports everything
export { add, subtract, PI } from './math.mjs';
export { default as greet } from './greet.mjs';
export * from './utils.mjs';
export * as helpers from './helpers.mjs';
Side-effect import (no bindings)
import './polyfills.mjs'; // executes the module, imports nothing
import 'reflect-metadata'; // common in decorator-heavy frameworks
CommonJS syntax
Node's original module system uses require() to load modules synchronously and module.exports (or the exports shorthand) to expose values. It is still the default for .js files in packages without "type": "module", and for any .cjs file regardless of package type.
// math.js — CJS exports
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
const PI = 3.14159;
module.exports = { add, subtract, PI };
// Alternative: add properties to the exports shorthand
exports.multiply = (a, b) => a * b; // OK
// exports = { ... } // WRONG — reassigning exports breaks CJS
// main.js — CJS require
const { add, PI } = require('./math'); // destructure
const math = require('./math'); // whole object
const express = require('express'); // npm package (no path prefix)
__dirname and __filename
CJS provides two global-like variables that give the current file's location:
const path = require('path');
console.log(__dirname); // /home/user/my-app/src
console.log(__filename); // /home/user/my-app/src/main.js
const configPath = path.join(__dirname, '../config.json');
How Node determines the module system
Node.js uses this priority order:
.mjsextension → always ESM..cjsextension → always CJS..jsextension → check the nearestpackage.jsonancestor:"type": "module"→ ESM"type": "commonjs"or missing → CJS
.json,.nodefiles are always CJS.
project/
├── package.json ← "type": "module"
├── src/
│ ├── index.js ← ESM (inherits "type": "module")
│ ├── legacy.cjs ← CJS (extension overrides)
│ └── helper.mjs ← ESM (extension)
└── scripts/
└── package.json ← "type": "commonjs" (nested override)
└── build.js ← CJS (inherits nested package.json)
Dynamic import()
import() is an async function that loads any module on demand. It works in both CJS and ESM files and is the only way to load an ESM module from CJS.
// Works in CJS or ESM
async function loadPlugin(name) {
const plugin = await import(`./plugins/${name}.mjs`);
return plugin.default;
}
// Conditional loading
if (process.env.DEBUG) {
const { inspect } = await import('node:util');
console.log(inspect(obj, { depth: 4 }));
}
Output:
{ foo: 'bar', nested: { a: 1, b: 2 } }
import.meta
ESM-only object that provides information about the current module:
// ESM only (.mjs or "type": "module")
console.log(import.meta.url); // file:///home/user/my-app/src/index.mjs
console.log(import.meta.dirname); // /home/user/my-app/src (Node.js 21.2+)
console.log(import.meta.filename); // /home/user/my-app/src/index.mjs (Node.js 21.2+)
// Resolve a path relative to this file (all Node versions)
const resolved = import.meta.resolve('./config.json');
Top-level await (ESM only)
In ESM files, await can appear at the top level of a module — outside any async function. This is not allowed in CJS.
// config.mjs — top-level await
const config = await fetch('https://api.example.com/config').then(r => r.json());
export const timeout = config.timeout;
// db.mjs — sequential async init at module level
import { createConnection } from './db-driver.mjs';
export const db = await createConnection(process.env.DATABASE_URL);
Modules that use top-level
awaitwill block the import of any module that depends on them. Use it for initialization that must complete before the module is usable, not for optional background work.
CJS / ESM interop
ESM importing CJS
ESM can import a CJS module. The entire module.exports value becomes the default export. Named imports are supported for simple object exports (Node.js static analysis), but they can fail for dynamically assembled exports.
// legacy.cjs
module.exports = { foo: 1, bar: 2 };
// consumer.mjs
import legacy from './legacy.cjs'; // works — default is { foo: 1, bar: 2 }
import { foo, bar } from './legacy.cjs'; // usually works for static object exports
CJS importing ESM (you cannot use require())
require() is synchronous; ESM modules are asynchronous by design. You cannot require() an .mjs file or a "type": "module" package.
// WRONG — throws ERR_REQUIRE_ESM
const esm = require('./some-esm-module.mjs');
Use dynamic import() instead:
// RIGHT — CJS file loading ESM asynchronously
async function run() {
const { default: chalk } = await import('chalk');
console.log(chalk.green('success'));
}
run();
Pure-ESM npm packages (like
chalkv5+,execav6+,gotv12+) cannot berequire()d. You must useimport()or stay on the last CJS version. This is one of the most common Node.js migration pain points.
Common pitfalls
__dirname and __filename do not exist in ESM
// WRONG in ESM
const dir = __dirname; // ReferenceError: __dirname is not defined
// CORRECT in ESM — reconstruct from import.meta.url
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Or use the Node 21.2+ globals directly
const __dirname = import.meta.dirname;
Default export confusion between CJS and ESM
A CJS module that sets module.exports = myFunction will appear as the default when imported via ESM:
// greet.cjs
module.exports = function greet(name) { return `Hi ${name}`; };
// consumer.mjs
import greet from './greet.cjs'; // works
import { greet } from './greet.cjs'; // WRONG — no named export "greet"
JSON imports
// CJS — works natively
const pkg = require('./package.json');
// ESM — requires import assertion (Node.js 22+) or fs.readFileSync
import pkg from './package.json' with { type: 'json' };
// Universal fallback
import { readFileSync } from 'node:fs';
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)));
Bundler module resolution vs Node resolution
Node follows the "exports" field strictly. Most bundlers (Webpack, Rollup, Vite, esbuild) also honour "exports" but add their own conditions:
| Condition | Node | Webpack | Rollup | Vite |
|---|---|---|---|---|
"import" | Yes | Yes | Yes | Yes |
"require" | Yes | Yes | Yes | Yes |
"browser" | No | Yes | Yes | Yes |
"module" | No | Webpack 5 | Yes | Yes |
"development" | No | No | No | Yes |
"production" | No | No | No | Yes |
Always test your library's "exports" in Node.js directly, not just through a bundler — what works in Vite may not work with node --input-type=module.
ESM module graph — parse, link, evaluate
A useful mental model: an ESM graph passes through three phases that explain almost every confusing import behaviour.
- Parse — every reachable
importstatement is collected statically, before any code runs. Specifiers must be string literals; you cannot use a variable in a top-levelimport. - Link — every binding is wired up. Each
import { name }becomes a live, read-only reference to the exporter's binding (not a snapshot of its current value). - Evaluate — modules execute in depth-first post-order. A module runs only after all its dependencies have completed evaluation.
// counter.mjs — exports a live binding, not a value
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 — the imported binding tracks the exporter's variable
// count = 5; // TypeError: Assignment to constant variable. (imports are read-only)
This live-binding semantic is the single biggest behavioural difference between ESM and CJS. In CJS, const { count } = require('./counter') snapshots the value at require time, and mutating count from the inside is invisible to the outsider.
Circular dependencies
ESM handles circular imports more gracefully than CJS because of live bindings — but only for function declarations and export let/export const. A circular import will see undefined for any binding that hasn't been initialised at the point it's read.
// a.mjs
import { b } from './b.mjs';
export const a = 'a';
console.log('a sees b:', b);
// b.mjs
import { a } from './a.mjs';
export const b = 'b';
console.log('b sees a:', a); // undefined — a.mjs hasn't reached its export yet
Output:
b sees a: undefined
a sees b: b
The cure is the same in both module systems: extract the shared symbol into a third module that neither circular party initialises late.
package.json fields that matter
A modern package.json combines several fields to describe its module shape, entry points, and supported runtimes. Get these wrong and your library breaks in subtle, bundler-specific ways.
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.mjs",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.mjs"
},
"./package.json": "./package.json",
"./browser": {
"types": "./dist/types/browser.d.ts",
"import": "./dist/esm/browser.mjs",
"default": "./dist/esm/browser.mjs"
}
},
"files": ["dist"],
"engines": { "node": ">=18" },
"sideEffects": false
}
| Field | Purpose |
|---|---|
"type" | Sets the default for .js files in this package. "module" → ESM; absent or "commonjs" → CJS. |
"main" | Legacy single entry — used when "exports" is absent or pre-Node 12 consumers resolve. |
"module" | Pre-"exports" convention for ESM-aware bundlers. Ignored by Node. |
"types" | TypeScript declarations entry (legacy). Prefer the "types" condition inside "exports". |
"exports" | The modern entry-point map. Overrides "main". Restricts what consumers can reach. |
"files" | Whitelist of paths to include in the published tarball. |
"sideEffects" | false lets bundlers tree-shake aggressively; ["./polyfill.js"] whitelists specific side-effecting files. |
Conditional exports — picking the right entry point
The "exports" field is a conditional resolver. Conditions are matched in order — list more specific ones first. Common conditions in production code:
"exports": {
".": {
"types": "./dist/index.d.ts",
"node": {
"import": "./dist/node.mjs",
"require": "./dist/node.cjs"
},
"browser": "./dist/browser.mjs",
"deno": "./dist/deno.mjs",
"default": "./dist/index.mjs"
}
}
Ordering rules:
"types"MUST come first so TypeScript's--moduleResolution bundler/nodenextresolver picks it up before falling through.- More-specific conditions (
"node","browser","deno") come before generic ones ("import","require"). "default"is the fallback; it matches when nothing else does.
Subpath patterns
Use * in "exports" keys to expose multiple files without listing each one. The match is purely substring-based — no glob semantics.
"exports": {
".": "./dist/index.mjs",
"./icons/*": "./dist/icons/*.mjs",
"./locales/*.json": "./locales/*.json"
}
import StarIcon from 'my-lib/icons/star'; // resolves to ./dist/icons/star.mjs
import enUS from 'my-lib/locales/en-US.json';
Dual-package hazard
A "dual package" ships both CJS and ESM builds. If consumers reach both copies (e.g. one direct dependency uses ESM, a transitive one uses CJS) they get two independent module instances — separate state, instanceof checks failing, and Symbol registries diverging. This is the dual-package hazard.
my-app
├── direct-dep → my-lib (ESM build)
└── transitive-dep → my-lib (CJS build)
→ two copies of my-lib in memory
→ instanceof MyLibError can be true for one and false for the other
Mitigations (pick one):
- ESM-only package — drop the CJS build, document the migration. Cleanest. Forces some downstreams to upgrade.
- CJS-only package — exposes a thin
.mjswrapper that re-exports from the CJS module viadefault. One instance, but ESM consumers lose tree-shakeability. - Pure package (no global state) — if your library is purely functional with no module-level mutation, two copies are harmless.
// dist/index.mjs — the wrapper approach
import cjsBuild from './index.cjs';
export const { foo, bar, baz } = cjsBuild;
export default cjsBuild;
Import maps (browser)
Import maps let the browser resolve bare specifiers (import 'lodash') without a bundler. Declare them inline in HTML; the browser parses the map before resolving any <script type="module">.
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/lodash.js",
"@/": "/src/",
"react": "https://esm.sh/react@18"
},
"scopes": {
"/legacy/": {
"react": "https://esm.sh/react@17"
}
}
}
</script>
<script type="module">
import { debounce } from 'lodash';
import App from '@/App.js';
</script>
Import maps were standardised in 2023; supported in Chrome 89+, Firefox 108+, Safari 16.4+. Node does not currently honour browser import maps (it has its own subpath imports via the "imports" field in package.json).
Node subpath imports
Package-local imports (the "imports" field, starting with #) work in Node and bundlers alike, with the same conditional-exports machinery as "exports".
{
"imports": {
"#config": {
"production": "./config/prod.js",
"default": "./config/dev.js"
},
"#utils/*": "./src/utils/*.js"
}
}
import config from '#config';
import { format } from '#utils/format';
These imports are private to the package — they cannot be reached from outside. They are the canonical replacement for ../../../utils/... relative imports.
Dynamic import — beyond the basics
Dynamic import() returns a Promise that resolves to a module namespace object containing every named export plus default. It works in both CJS and ESM and is the only async-safe way to load a module whose specifier is unknown at parse time.
// Conditional / lazy load
const mod = process.env.DEBUG
? await import('./debug-impl.mjs')
: await import('./prod-impl.mjs');
// Specifier built from a variable
async function loadLocale(lang) {
const m = await import(`./locales/${lang}.mjs`);
return m.default;
}
// Just the default export
const { default: createServer } = await import('./server.mjs');
// All named exports, no default
const { add, subtract } = await import('./math.mjs');
Why dynamic import is also useful in browsers
For code splitting and route-level lazy loading, dynamic import is the unit of laziness — bundlers emit a separate chunk per import() call.
// Lazy-load a heavy editor only when the user opens the editor view
button.addEventListener('click', async () => {
const { Editor } = await import('./editor.mjs');
new Editor(document.getElementById('app'));
});
Bundlers can only split on
import()calls whose specifier is a string literal.import(userInput)produces no chunk and instead bundles every reachable module — defeating the optimisation.
Top-level await — implications
Top-level await suspends the module until the awaited promise settles. Every consumer that imports the module — directly or transitively — waits too. This is fine for one-shot initialisation but catastrophic for hot paths.
// db.mjs
export const db = await connectDatabase(); // every importer waits for the connect
// routes.mjs
import { db } from './db.mjs';
// ↑ this import is itself paused until db.mjs's TLA settles
export function getUser(id) {
return db.users.findOne({ id });
}
The whole subgraph that depends on db.mjs will not begin executing until connectDatabase() resolves. Use TLA for "config that must exist before anything else can run" and nothing else.
Async module ordering — the deadlock trap
Two modules with TLA that import each other cyclically deadlock. The runtime cannot resolve either because each is waiting for the other to finish initialising.
// a.mjs
import './b.mjs';
await waitFor('signal-from-b'); // deadlocks
export const a = 1;
// b.mjs
import './a.mjs';
await waitFor('signal-from-a'); // deadlocks
export const b = 2;
The runtime detects this and throws TypeError: Top-level await cannot be used in modules with circular dependencies that have not finished evaluating — but the error message is misleading. The real fix is to extract async initialisation into an explicit init() function called by the entry point.
Source maps
Source maps are a JSON sidecar that maps positions in transpiled / bundled output back to the original source. Without them, stack traces from a minified production bundle point to lines like dist/bundle.js:1:8741 — unreadable. With them, the browser shows the original TypeScript / ESM source.
// At the bottom of dist/bundle.js
//# sourceMappingURL=bundle.js.map
| Tool | Flag |
|---|---|
tsc | --sourceMap |
esbuild | --sourcemap |
vite | build.sourcemap: true in vite.config |
webpack | devtool: 'source-map' |
rollup | output.sourcemap: true |
For Node.js, set NODE_OPTIONS=--enable-source-maps (or pass the flag directly). Stack traces in errors will then point to the original source positions.
NODE_OPTIONS=--enable-source-maps node dist/index.mjs
Output:
TypeError: Cannot read properties of undefined (reading 'name')
at getUserName (src/users.ts:12:23)
at handler (src/routes/users.ts:8:18)
For production, decide whether to deploy source maps alongside the bundle (debuggable but exposes original source) or upload them to an error-tracking service (Sentry, Bugsnag) and serve only the minified bundle to the browser. The error tracker resolves the map server-side.
Bundler-specific quirks
Each major bundler has subtle deviations from Node's ESM resolution. The ones that bite most often:
| Quirk | Node | Webpack | Vite | esbuild | Rollup |
|---|---|---|---|---|---|
Bare-spec resolves to node_modules | yes | yes | yes (dev: pre-bundle) | yes | needs plugin |
Honours "exports" field | yes | yes | yes | yes | yes |
Honours "browser" field | no | yes | yes | yes | yes |
| File extension required in import | yes for relative | no | yes for relative | no | no |
import() of JSON | needs assertion | yes | yes | yes | yes |
| Top-level await | yes (ESM only) | yes (experimental) | yes | yes | yes |
| Tree-shakes side-effecting imports | n/a (no shake) | needs "sideEffects": false | yes | yes | yes |
The biggest day-to-day surprise is file extensions. Native Node ESM requires import './foo.mjs' — the .mjs is mandatory. Vite and esbuild silently let you write import './foo'. This means code that runs in Vite often fails in production when handed to Node directly.
Common pitfalls
- Mixing
module.exports = …andexports.foo = …— assigning tomodule.exportsreplaces the object; subsequentexports.fooupdates a now-orphaned reference and consumers see nothing. - Forgetting the
.js/.mjsextension in native Node ESM — Node enforces extensions on relative specifiers. The bundler may not, but production will. - Trying to
require()an ESM package — throwsERR_REQUIRE_ESM. Use dynamicimport()or downgrade to a CJS version of the package. import * as ns from './foo.cjs'and findingns.defaultis the real export — when a CJS file doesmodule.exports = function() {}, the function is exposed only as the default.- JSON imports breaking after a Node upgrade —
import data from './x.json'started requiringwith { type: 'json' }in modern Node. Usefs.readFileSyncfor portability. __dirnamein ESM — does not exist. Useimport.meta.dirname(Node 21.2+) orfileURLToPath(import.meta.url).- TypeScript's
esModuleInteroplying about the runtime — theimport foo from 'cjs-pkg'you wrote in TS works because of synthetic-default imports. The compiled JS may not behave the same way under native Node ESM. - Bundler-only specifiers —
import 'fs'works in Node, fails in browsers.import './app.css'works in Webpack, fails in Node. Keep the boundary clean. - Path aliases not surviving production —
@/utilsworks under Vite, fails undernode dist/index.mjs. The bundler must rewrite or you must register aliases at runtime (node --importwith a resolver hook). - Top-level await blocking process exit — a TLA waiting on
setInterval(...)keeps the event loop alive forever. Always pair long-lived async init with an explicit shutdown path.
Real-world recipes
Synthesizing __dirname and __filename in ESM
The Node 21.2+ globals are the cleanest path; the longer form below works in older Node releases too.
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Node 21.2+
const here = import.meta.dirname;
Reading a sibling JSON file portably
import { readFileSync } from 'node:fs';
const pkg = JSON.parse(
readFileSync(new URL('./package.json', import.meta.url), 'utf8')
);
console.log(pkg.version);
Output:
1.0.0
Conditional ESM/CJS detection
When a library must work in both module systems and behave slightly differently in each, sniff at runtime.
// In CJS, `module` is defined; in ESM it is not.
const isESM = typeof module === 'undefined';
if (isESM) {
console.log('running as ESM');
} else {
console.log('running as CJS');
}
Output:
running as ESM
Plugin loader with dynamic import
A canonical pattern for tools that load user-provided plugins by path or package name.
async function loadPlugins(specs) {
return Promise.all(
specs.map(async (spec) => {
const mod = await import(spec);
const plugin = mod.default ?? mod;
if (typeof plugin.register !== 'function') {
throw new TypeError(`plugin ${spec} has no register()`);
}
return plugin;
})
);
}
const plugins = await loadPlugins(['./plugins/auth.mjs', 'eslint-plugin-x']);
Lazy-load a heavy dependency
For CLIs with multiple subcommands, don't import everything at startup — defer the heavy modules until they are needed.
// cli.mjs
const command = process.argv[2];
switch (command) {
case 'build':
(await import('./commands/build.mjs')).run();
break;
case 'serve':
(await import('./commands/serve.mjs')).run();
break;
default:
console.error(`unknown command: ${command}`);
process.exit(1);
}
Startup time drops to the cost of resolving and parsing cli.mjs itself.
A dual-shape build with tsup
The simplest path to shipping both ESM and CJS plus types — let tsup handle the bundling so the exports map is autogenerated.
npx tsup src/index.ts --format esm,cjs --dts --clean
Output:
ESM ⚡ Build success in 230ms
CJS ⚡ Build success in 248ms
DTS ⚡ Build success in 412ms
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}