ESM vs CommonJS: Interop & Migration
Node.js supports two module systems that look superficially similar but behave very differently under the hood: CommonJS (CJS), the original system built on require() and module.exports, and ECMAScript Modules (ESM), the standardized system built on import and export. Knowing how they differ — and how they interoperate — matters because most real projects mix packages from both worlds, and the boundary between them is where the trickiest bugs live. This page focuses on the practical differences, the rules for calling one from the other, and a safe strategy for migrating.
How Node decides which system a file uses
Node picks the module system per file, based on a small set of signals:
| Signal | Result |
|---|---|
File extension .mjs | Always ESM |
File extension .cjs | Always CommonJS |
.js with "type": "module" in nearest package.json | ESM |
.js with "type": "commonjs" or no type field | CommonJS |
So the same index.js can be CJS or ESM depending solely on the type field of the closest package.json. This is the single most common source of confusion, so set "type" explicitly in every package you author.
Core behavioral differences
The two systems diverge in loading model, scope, and timing.
| Aspect | CommonJS | ES Modules |
|---|---|---|
| Import syntax | require() | import |
| Export syntax | module.exports | export / export default |
| Loading | Synchronous | Asynchronous, resolved up front |
| Bindings | Copied value | Live, read-only bindings |
this at top level | module.exports | undefined |
__dirname / __filename | Available | Not available (use import.meta) |
Top-level await | Not allowed | Allowed |
| Strict mode | Opt-in | Always on |
Because ESM resolves the whole graph before executing, imports are hoisted and statically analyzable. CommonJS require() runs in-place, so you can call it conditionally inside a function — something static import cannot do.
Top-level await
ES modules can await at the top level, which is invaluable for configuration that depends on async work such as a fetch or a database connection. CommonJS has no equivalent.
// config.mjs — ESM only
const res = await fetch('https://example.com/feature-flags.json');
export const flags = await res.json();
Any module importing config.mjs waits for that top-level await to settle before its own code runs, so consumers always see fully resolved data.
Importing CommonJS from ESM
This direction works smoothly. An ESM file can import a CJS module; Node exposes its module.exports as the default export. Named imports are also supported for statically detectable keys, but the default is always reliable.
// app.mjs
import express from 'express'; // express is CommonJS
import { readFile } from 'node:fs/promises';
const app = express();
const data = await readFile(new URL('./data.json', import.meta.url), 'utf8');
app.get('/', (req, res) => res.json(JSON.parse(data)));
app.listen(3000, () => console.log('listening on 3000'));
Output:
listening on 3000
Importing ESM from CommonJS
This direction is restricted: you cannot require() an ESM module synchronously on older runtimes, because ESM loads asynchronously. The portable solution is the dynamic import() expression, which returns a promise and works in both module systems.
// loader.cjs — CommonJS calling into an ESM package
async function main() {
const { default: chalk } = await import('chalk'); // chalk v5 is ESM-only
console.log(chalk.green('Loaded an ESM package from CommonJS'));
}
main();
Output:
Loaded an ESM package from CommonJS
Node 22 added experimental synchronous
require()of ESM graphs that contain no top-levelawait(behind a flag on some releases, on by default later). It is still safest to use dynamicimport()for cross-system loading when you need broad compatibility.
Recreating __dirname in ESM
ES modules have no __dirname or __filename. Derive them from import.meta.url. Node 20.11+ also exposes import.meta.dirname and import.meta.filename directly.
// paths.mjs
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname);
// Modern shortcut (Node 20.11+):
console.log(import.meta.dirname);
Output:
/home/app/src
/home/app/src
Dual-package hazards
A “dual package” ships both a CJS and an ESM build so it works everywhere, usually via the exports field’s conditional import/require keys. The hazard is that a single process can then load both copies — the ESM build through import and the CJS build through require — giving you two separate module instances with separate state. If the package relies on internal singletons (a cache, an event bus, an instanceof check), they silently break across the two copies.
// package.json of a dual package
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
Mitigate by keeping stateless logic in one shared module and having both entry points re-export it, or by publishing ESM-only and letting consumers reach you via dynamic import().
Migration strategy
Migrating a CommonJS codebase to ESM works best incrementally:
- Upgrade to a current LTS (Node 20 or 22) and confirm your dependencies have ESM-compatible versions.
- Add
"type": "module"topackage.json, then rename any files that must stay CJS (build scripts, config tooling) to.cjs. - Replace
require()withimport, andmodule.exportswithexport/export default. - Swap
__dirname/__filenameforimport.meta.dirname/import.meta.filename. - Replace dynamic
require(variable)calls withawait import(variable). - Add an
exportsmap so downstream consumers get the right entry point.
Best practices
- Always set
"type"explicitly inpackage.jsonso a file’s module system is never ambiguous. - Use dynamic
import()whenever you must load ESM from CommonJS, or load a module conditionally. - Prefer
import.meta.dirname/import.meta.filenameover manually reconstructing__dirname. - For new code, default to ESM and reserve
.cjsfor tooling that genuinely requires CommonJS. - Avoid dual packages where you can; if you must ship one, keep all stateful logic in a single shared module to dodge the dual-instance hazard.
- Migrate file-by-file rather than all at once, leaning on the
.mjs/.cjsextensions to mix systems during the transition.