Featured image of post ESM vs CommonJS Interop Challenges in Node.js Featured image of post ESM vs CommonJS Interop Challenges in Node.js

ESM vs CommonJS Interop Challenges in Node.js

Learn ESM / CJS module boundary interop, understanding paths, exports syntax, and import.meta differences.

Introduction

Node.js has supported ECMAScript Modules (ESM) since v12, but the ecosystem remains deeply rooted in CommonJS (CJS). Mixing the two module systems in a single project — or consuming CJS packages from ESM code — introduces subtle pitfalls around exports, default imports, and globals. This article maps the interop boundary and provides concrete strategies for a smooth migration.


CJS vs ESM at a Glance

AspectCommonJSESM
File extensions.js, .cjs.js, .mjs
Default in package.json"type": "commonjs""type": "module"
LoadingSynchronous (require())Asynchronous (import)
Top-level thismodule.exportsundefined
__dirname / __filenameAvailableNot available
Live bindingsCopy exportsLive bindings

Configuring the Module System

You control the module system via the package.json "type" field:

{
  "type": "module"
}
  • Files with .mjs extension are always treated as ESM.
  • Files with .cjs extension are always treated as CJS.
  • Files with .js extension default to what "type" specifies.

Importing CJS from ESM

Node.js allows ESM to import CJS modules. The CJS module.exports is mapped to the default export of the ESM import:

// cjs-module.js
module.exports = { hello: 'world' };
// esm-module.mjs
import cjsModule from './cjs-module.js';
console.log(cjsModule.hello); // "world"

Named Exports Quirk

Named exports from CJS are auto-detected via static analysis in Node.js — but with limitations. Dynamic or conditional exports may not resolve correctly:

// This works only if Node can statically analyze the exports
import { hello } from './cjs-module.js';

If you encounter export not found errors, fall back to importing the entire default:

import cjsModule from './cjs-module.js';
const { hello } = cjsModule;

Importing ESM from CJS

CJS cannot require() ESM modules. You must use dynamic import(), which returns a promise:

// cjs-file.cjs
async function loadEsm() {
  const esmModule = await import('./esm-module.mjs');
  console.log(esmModule.namedExport);
}

This is especially relevant when writing config files (e.g., webpack, ESLint) that remain in CJS but need to load ESM plugins.


__dirname Alternatives in ESM

CJS provides __dirname and __filename globals. ESM replaces them with import.meta:

import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

For a more concise approach, use the import.meta.resolve API (experimental in older Node versions):

const resolvedPath = import.meta.resolve('./relative-path.js');

Key Interop Pitfalls

1. Default Export Confusion

CJS exports a single object — there is no true “default” vs “named.” When you write:

// CJS
exports.default = foo;
exports.named = bar;

ESM interprets this as:

import { default as foo, named as bar } from './cjs.js';

But import cjs from './cjs.js' binds to module.exports, which may not be what the CJS author intended.

2. JSON Modules

In CJS, require('./data.json') works synchronously. In ESM, you need an import assertion:

import data from './data.json' with { type: 'json' };

3. Top-Level Await

ESM supports top-level await; CJS does not. If you need top-level await in a CJS context, wrap your code in an async IIFE.


Migration Strategy

StepAction
1Add "type": "module" to package.json
2Rename files to .mjs or update imports to use full extensions
3Replace require() with import — use dynamic import() for CJS files
4Replace __dirname / __filename with import.meta.url patterns
5Update any dependency that cannot be imported to await import()

For large codebases, consider a dual-package approach: ship both CJS and ESM builds using "exports" in package.json:

{
  "exports": {
    "import": "./dist/index.mjs",
    "require": "./dist/index.cjs"
  }
}

Conclusion

ESM is the future of Node.js module systems, but the transition from CJS requires careful handling of interop boundaries. Understanding how default exports map, when to use dynamic import(), and how to replace CJS globals with import.meta.url will save you from runtime surprises. A phased migration, combined with dual-package exports, allows you to support both systems without breaking existing consumers.