# 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.

- Published: 2026-10-11
- Tags: nodejs, ecosystem
- Source: https://jsledger.com/blog/require-esm-is-stable/
- Language: en-US
- Author: Jonah Vail

---
A package that keeps a registry at module scope, loaded both ways in one
process:

```js
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? false
```

Two 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:

```yaml
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:

```json
"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](/blog/reading-a-lockfile/) 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:

```json
{ "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,register
```

Same 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? true
```

Node 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_MODULE
```

That 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? true
```

Two `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:

```js
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](/blog/vite-8-replaced-two-bundlers-with-one/).

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.
