Ecosystem and productionThe software supply chainPart 3
require(esm) Killed the Second Build, Not the Second Copy
Being able to require() an ES module removes the reason most packages shipped two builds. The dual package hazard is a layout problem, and it survived.

A package that keeps a registry at module scope, loaded both ways in one process:
import * as esm from 'registry';
const cjs = require('registry');
esm.register(new esm.Plugin('a'));
cjs.register(new cjs.Plugin('b'));$ node hazard.mjs # Node 26.8.1
esm.format = esm | cjs.format = cjs
same module object? false
esm sees 1 plugin(s); cjs sees 1
cjs instance instanceof esm.Plugin? falseTwo plugins registered, and each half of the program can see one of them. This is the dual package hazard, and it is running on a Node that no longer needs the package to be dual at all.
What actually changed
Node’s own documentation is the place to settle the status, because the
secondary sources disagree with each other. Under Loading ECMAScript modules
using require() in doc/api/modules.md, the change history reads:
changes:
- version: v25.4.0
pr-url: https://github.com/nodejs/node/pull/60959
description: This feature is no longer experimental.So require() of an ES module is a supported way to load code, on every line
still in support. That removes the argument that carried dual publishing: you
shipped a CommonJS build because some consumer’s require() could not reach your
ESM one. It can now.
One piece of noise worth naming, since it looks like a contradiction. Turn on the tracing flag and Node prints this for every such load:
$ node --trace-require-module=all single.mjs
(node:2637) ExperimentalWarning: CommonJS module …/single.mjs is loading
ES Module …/node_modules/single/index.mjs using require().The plain run prints nothing. That is the diagnostic flag’s own wording, not the
feature’s status, and --trace-require-module is documented as printing
“information about usage” rather than as a stability warning.
The hazard was never about the format
Removing the reason for two builds does not remove two builds. The package in the opening still declares both:
"exports": {
".": { "require": "./index.cjs", "import": "./index.mjs" }
}Node resolves the condition that matches the caller, and the two files are
separate modules with separate scopes. Each one evaluates its own new Map().
Each one defines its own class Plugin, and a class is its own identity, so
instanceof across the boundary is false — the same reason a
lockfile’s integrity hash tells you a tarball did
not change and nothing about how many times it is instantiated.
The hazard is therefore a property of the layout, and it bites exactly when the
module holds something: a registry, a cache, a connection pool, a singleton
config, a class used with instanceof. A package of pure functions can be dual
forever and nobody notices.
The layout that makes it impossible
Delete the second file. One entry, reachable from every condition:
{ "name": "single", "type": "module", "exports": "./index.mjs" }$ node single.mjs # same script as before
same register fn? true
esm sees 2 | require sees 2
instanceof across the boundary? true
require() returned: Plugin,count,format,registerSame function identity, one Map holding both plugins, working instanceof, and
require() handing back the named exports as properties.
The word to be precise about is reachable. A "require" condition pointing at
a second file reintroduces the hazard even if that file is a one-line re-export,
because a re-export in CommonJS is a new module with a new scope. What matters is
that every condition resolves to the same path, which is what a single string in
exports guarantees.
What the CommonJS caller actually receives
Dropping the second build is a breaking change for one shape of consumer, and it
is worth seeing before you ship it. require() of an ES module returns an
object, always:
$ node interop.cjs
keys: __esModule,default,named
typeof m: object | callable? false
m.default is the fn? true
__esModule? trueNode sets __esModule and puts the default export on .default. If your
CommonJS build ended in module.exports = function main() {…}, every consumer
writing const main = require('pkg'); main() breaks on the ESM-only version,
because the object it now gets is not callable. They need .default, and that is
a major-version change however small the diff looks.
One more refusal belongs in the same paragraph. A module with top-level await
cannot be required at all, settled or not:
top-level await -> ERR_REQUIRE_ASYNC_MODULEThat is not a race and no retry fixes it. require() is synchronous, and an
async module has no synchronous answer to give.
The caching does behave the way a CommonJS caller expects, which is the part nobody thinks to check:
$ node cache.cjs
two requires, same object? true
in require.cache? trueTwo require() calls return the identical object, and the entry appears in
require.cache under its resolved path. Tooling that walks that cache, such as
test runners clearing modules between suites and hot-reload shims, keeps working
on an ESM-only package.
The new sharp edge
Node 26 added an error for a case that used to be an internal assertion. Start an
import() and require() the same module before it settles:
const p = import('./slow.mjs'); // slow.mjs has a top-level await
const m = require('./slow.mjs');code: ERR_REQUIRE_ESM_RACE_CONDITION
message: Cannot require() ES Module …/slow.mjs because it is not yet fully loaded.
This may be caused by a race condition if the module is simultaneously
dynamically import()-ed via Promise.all().
Try await-ing the import() sequentially in a loop instead.doc/api/errors.md marks it added: v26.1.0, Stability 1 - Experimental. It
also fires on Node 24.20.0, so it reached the LTS line as well. The condition is
narrow — it needs a module that is genuinely still loading, which in practice
means top-level await — but Promise.all over a list of dynamic imports is a
common enough shape that the error message names it directly.
What to check in a tree you did not write
--trace-require-module=all prints every ESM-loaded-by-require event in a
run, and --trace-require-module=no-node-modules narrows it to your own code.
That is how you find out whether the hazard is even reachable in an application
before auditing exports maps by hand.
For a package you publish, the test is one script: import it and require it in the same process, then compare a function’s identity and register something through both handles. If the counts disagree, you have two copies, and no Node version is going to fix that for you — the same way a dev server and a production build can run the same transformer and still be two code paths.
What I did not test: exports maps with more conditions than require and
import — node, browser, development — where a bundler’s resolution can
disagree with Node’s and produce the hazard for one consumer and not another.
That is where I would expect the remaining surprises to live, and it needs a
bundler in the loop to measure honestly.
FAQ
Frequently asked
Is require(esm) still experimental?
No. Node documents the change under Loading ECMAScript modules using require() with the note "This feature is no longer experimental" against v25.4.0, PR 60959. Running it prints no warning. The --trace-require-module flag still labels its output ExperimentalWarning, which is the flag talking, not the feature.
Does dropping the CommonJS build fix the dual package hazard?
Yes, if dropping it means one file is reachable from every exports condition. Measured on Node 26.8.1, an ESM-only package required and imported in the same process gives identical function identities, one shared Map, and instanceof that works across the boundary.


