Skip to content
Node.js nd modules 5 min read

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:

SignalResult
File extension .mjsAlways ESM
File extension .cjsAlways CommonJS
.js with "type": "module" in nearest package.jsonESM
.js with "type": "commonjs" or no type fieldCommonJS

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.

AspectCommonJSES Modules
Import syntaxrequire()import
Export syntaxmodule.exportsexport / export default
LoadingSynchronousAsynchronous, resolved up front
BindingsCopied valueLive, read-only bindings
this at top levelmodule.exportsundefined
__dirname / __filenameAvailableNot available (use import.meta)
Top-level awaitNot allowedAllowed
Strict modeOpt-inAlways 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-level await (behind a flag on some releases, on by default later). It is still safest to use dynamic import() 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:

  1. Upgrade to a current LTS (Node 20 or 22) and confirm your dependencies have ESM-compatible versions.
  2. Add "type": "module" to package.json, then rename any files that must stay CJS (build scripts, config tooling) to .cjs.
  3. Replace require() with import, and module.exports with export / export default.
  4. Swap __dirname/__filename for import.meta.dirname/import.meta.filename.
  5. Replace dynamic require(variable) calls with await import(variable).
  6. Add an exports map so downstream consumers get the right entry point.

Best practices

  • Always set "type" explicitly in package.json so 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.filename over manually reconstructing __dirname.
  • For new code, default to ESM and reserve .cjs for 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/.cjs extensions to mix systems during the transition.
Last updated June 14, 2026
Was this helpful?