Most Node.js projects today mix the two module systems whether they mean to or not. Your code might be ESM while half of node_modules is CommonJS, or you maintain an older CJS service that now depends on a package that went ESM-only. The interop rules are mostly invisible until one of them produces an error like Named export 'titleCase' not found, and then you need to know what Node is actually doing.
Everything below ran on Node 22.23. Interop changed quite a bit across the 20 and 22 release lines, so where behaviour depends on the version, the version is stated.
How Node decides which system a file uses
The decision is made per file, not per project:
.mjsfiles are always ES modules..cjsfiles are always CommonJS.- A
.jsfile follows the"type"field of the nearestpackage.jsonabove it:"module"means ESM,"commonjs"or no field means CommonJS. - Since Node 22.7, a
.jsfile with no"type"in scope that fails to parse as CommonJS but containsimport/exportis re-run as ESM. It works, with a warning:
(node:24913) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///.../detect/a.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /.../detect/package.json.Take the warning’s advice. Detection is a convenience for scripts, and parsing every file twice isn’t something you want in a service.
The two systems differ in more than syntax. CommonJS require() is synchronous and returns whatever module.exports is at that moment. ESM is resolved and linked before any code runs, which is what lets ES modules use top-level await and lets tools tree-shake them. ESM also follows URL rules: relative imports need the full filename. import './util' fails with ERR_MODULE_NOT_FOUND even though ./util.mjs is sitting right there, where require('./util') would have tried the extensions for you.
ESM files also lose a few CommonJS conveniences. __dirname, __filename and require aren’t defined in an ES module (typeof __dirname is 'undefined', and calling require throws require is not defined). Use import.meta.dirname and import.meta.filename (Node 20.11 and later), and createRequire(import.meta.url) from node:module if you truly need require inside ESM.
Importing CommonJS from an ES module
This direction has always worked, but with a rule that surprises people. Here’s a CJS utility module:
// legacy.cjs
exports.slugify = (s) => s.toLowerCase().replace(/\s+/g, '-');
exports.VERSION = '1.4.0';
const extra = { titleCase: (s) => s.replace(/\b\w/g, c => c.toUpperCase()) };
Object.assign(exports, extra);And an ES module using it:
// use-cjs.mjs
import legacy, { slugify, VERSION } from './legacy.cjs';
console.log(slugify('Hello World'), VERSION);
console.log('default keys:', Object.keys(legacy));
const ns = await import('./legacy.cjs');
console.log('namespace keys:', Object.keys(ns));hello-world 1.4.0
default keys: [ 'slugify', 'VERSION', 'titleCase' ]
namespace keys: [ 'VERSION', 'default', 'slugify' ]The default import is the whole module.exports object, every time. That’s the one guarantee. Named imports are a best effort: ES modules need their export names known before the CJS code runs, so Node scans the CommonJS source with a static analyser (cjs-module-lexer) looking for patterns like exports.name = and module.exports = { a, b }. It found slugify and VERSION. It can’t run Object.assign, so titleCase exists at runtime but isn’t in the namespace. Try to import it by name:
SyntaxError: Named export 'titleCase' not found. The requested module './legacy.cjs' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:The error message is the fix: import legacy from './legacy.cjs' and then const { titleCase } = legacy. You’ll hit this most with CJS packages whose exports are built in a loop, re-exported through a bundler’s helper, or assigned conditionally.
A related case is a module that exports a function with extra properties, a common CJS pattern:
// factory.cjs
function createClient(opts) { return { opts }; }
module.exports = createClient;
module.exports.defaults = { retries: 2 };import createClient from './factory.cjs' gives you the function, and createClient.defaults works as a property on it. Code that previously ran through Babel or TypeScript’s older interop may have written import * as createClient and called it; in real Node ESM a namespace object isn’t callable, so that breaks when you switch to native modules.
Requiring an ES module from CommonJS
For years this direction was the painful one: require() of an ES module threw ERR_REQUIRE_ESM, and the only way in was await import(). That changed. Node 22.12 enabled require(esm) by default (and 20.19 backported it to the 20 line), and on 22.23 it runs without a warning:
// use-esm.cjs
const util = require('./util.mjs');
console.log(Object.keys(util), util.add(2, 3), util.default('ana'));
try { require('./tla.mjs'); } catch (e) { console.log(e.code); }
(async () => {
const { add } = await import('./util.mjs');
console.log('dynamic import:', add(1, 1));
})();[ '__esModule', 'add', 'default' ] 5 hi ana
ERR_REQUIRE_ASYNC_MODULE
dynamic import: 2require returns the module namespace object. The default export is under .default, not the object itself, and Node adds __esModule so that transpiled code which checks that flag keeps working.
The exception is tla.mjs, which uses top-level await. require() is synchronous and can’t wait, so Node throws ERR_REQUIRE_ASYNC_MODULE. Dynamic import() works for any ES module from any CommonJS file and in every supported Node version; it just forces the calling code to be asynchronous. If you publish a library that CJS consumers on Node 18 or an early 20.x release might use, assume they can only reach you through import().
The dual package hazard
Many libraries publish both builds and pick one with conditional exports:
{
"name": "money",
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.cjs"
}
}
}That looks harmless, and for stateless helpers it is. But the two files are two separate modules. If your ESM app imports money and an older CJS plugin requires it, both copies load, each with its own Money class:
// app.mjs
import { Money } from 'money';
import { createRequire } from 'node:module';
import plugin from './old-plugin.cjs'; // does require('money')
const require = createRequire(import.meta.url);
console.log(plugin.price() instanceof Money);
console.log(require('money').Money === Money);false
falseThe plugin created a perfectly good Money, but it came from the CJS copy, so instanceof against the ESM copy fails. The same split hits singletons, registries, caches and anything using a WeakMap or Symbol as an identity key. Bugs like “my plugin’s error isn’t recognised by the error handler” often trace back here. Library authors avoid it by keeping one real implementation, usually CJS with a thin ESM wrapper that re-exports it, or, now that require(esm) is widely available, by shipping ESM only.
Interview practice: the upgrade that broke a CLI
“A team’s CommonJS command-line tool upgraded a dependency to its new major version. On the CI image, which runs Node 20.11, the CLI now crashes with ERR_REQUIRE_ESM. On a developer’s Node 22.14 laptop it works. A week later, after another minor update of the same dependency, it fails on Node 22 too, with ERR_REQUIRE_ASYNC_MODULE. Explain all three observations and the fix.”
Answer
The new major version became ESM-only. Node 20.11 predates require(esm) (it arrived unflagged in 22.12 and was backported to 20.19), so the CLI’s require('dep') throws ERR_REQUIRE_ESM. Node 22.12 and later can require() ES modules synchronously, which is why the same code worked on the laptop.
The minor update then added top-level await somewhere in the dependency’s module graph (loading a config file or a WebAssembly module at import time is a common reason). A graph containing top-level await can’t be loaded synchronously, so even Node 22 refuses with ERR_REQUIRE_ASYNC_MODULE.
The fix that works on every version is to load the dependency with dynamic import(): const { run } = await import('dep') inside the CLI’s async main. If the tool is small, converting it to ESM ("type": "module", .js extensions in relative imports, import.meta.dirname instead of __dirname) is cleaner and avoids the problem for the next ESM-only dependency. I’d also pin engines.node in package.json and run CI on the oldest Node version the tool claims to support, because “works on my laptop” here was really “works on a newer Node than production has”.