In earlier posts, I wrote about reviving require(esm) and its iteration process. The idea seems straightforward once you grasp the ESM semantics, but battle‑testing revealed interop edge cases rooted in existing ecosystem workarounds. Along the way, several escape hatches were devised to address them.

Faux-ESM: __esModule in the module namespace

As mentioned in the previous post, packages that bundle for browsers often ship transpiled CommonJS on Node.js (also known as “faux ESM”). A typical pattern looks like this:

1
2
3
4
5
6
// In package.json
{
"name": "faux-esm-package",
"main": "dist/index.js", // Transpiled CommonJS version recognized by Node.js
"module": "src/index.js" // Original ESM code recognized by bundlers
}

One might expect that with require(esm), a faux‑ESM package could now simply point main to src/index.js. But here the semantic mismatch between ESM and CommonJS gets in the way. Consider:

1
2
3
// src/index.js in ESM
export default class Klass {};
export function func() {}

Per the spec, when dynamically import()‑ed, the default export appears under the default property of the returned namespace object:

1
2
3
4
5
console.log(await import('faux-esm-package'));
// {
// default: class Klass{},
// func: [Function: func]
// }

This differs from CommonJS, where module.exports is returned directly by require(), so tools that transpile ESM into CommonJS needed a way to multiplex ESM exports in CommonJS. Over the years, a de facto convention emerged from Babel and was adopted widely by transpilers and bundlers: add a __esModule sentinel in transpiled exports to signal “was ESM”. The above ESM would transpile to:

1
2
3
4
5
// dist/index.js in transpiled CommonJS
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = class Klass {};
exports.func = function func() {};

When a faux-ESM consumer loads this, the transpiler-emitted code checks for __esModule and constructs a matching namespace, so that ESM-style imports work as expected:

1
2
3
// Source code of faux-ESM consumer
import Klass, { func } from 'faux-esm-package';
import Klass2 from 'commonjs-package'; // Suppose it does module.exports = Klass2;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Transpiled CommonJS code of faux-ESM consumer
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
// Since __esModule is present, faux_esm_package_1 would look like
// { default: Klass, func: [Function: func] }
const faux_esm_package_1 = __importDefault(require("faux-esm-package"));
const Klass = faux_esm_package_1.default;
const func = faux_esm_package_1.func;
// Since __esModule is not present, commonjs_package_1 would be the result
// returned by require(), i.e., Klass2 directly.
const commonjs_package_1 = __importDefault(require("commonjs-package"));
const Klass2 = commonjs_package_1.default;

After experimentation with require(esm) started, Nicolo Ribaudo pointed out that if a faux‑ESM package migrated to ESM‑only, when its faux‑ESM consumer require()‑d it, the namespace wouldn’t have this artificially added __esModule, breaking existing transpiled consumers:

1
2
3
4
5
// If __esModule is not added by a transpiler, faux_esm_package_1 would look like
// { default: { default: Klass, func: [Function: func] } }
const faux_esm_package_1 = __importDefault(require("faux-esm-package"));
const Klass = faux_esm_package_1.default; // It becomes the namespace object itself
const func = faux_esm_package_1.default.func; // undefined!

Given many interdependent faux‑ESM packages in the wild, this breakage would make independent migration difficult. To lower the transition cost, the most straightforward solution would be to add __esModule to the namespace object returned by require(esm) so that existing faux‑ESM consumers keep working.

Evaluation of different approaches to add __esModule

The idea seemed simple, but there was a caveat: module namespace objects are immutable per spec — even as the host, Node.js can’t mutate them to add __esModule. In a brainstorm thread, several approaches were proposed by folks from the community:

  1. Return a new object copy that adds __esModule
    • Exports on the ESM namespace appear as data properties per the spec. A plain copy would break the live binding, so a copy done outside the JS engine must forward each export access to the original namespace via other means, e.g., getters.
    • Getters, however, add overhead on every access. Transpiled consumers often use dynamic access (e.g., faux_esm_package_1.default in the example above) to maintain live bindings even if they appear to be “cached” in ESM source, so this overhead hits a hot path.
    • The return object isn’t a real namespace, breaking instanceof and some debugging utilities.
  2. Return a Proxy backed by the namespace that intercepts __esModule
    • Addresses the identity issue in most cases.
    • Still adds overhead on every export access.
  3. Object.create(namespace, { __esModule: { value: true } }), which was what Bun used in their implementation and suggested by Jarred Sumner
    • Faster on access via prototype lookup, but identity issues remain.
    • Breaks enumerability of exported names, as they are now tucked away in the prototype.
  4. Use an internal ESM facade that export * from + export { default } from the original module, with an additional export const __esModule = true;
    • Returns a real namespace and preserves identity and enumerability.
    • Access via export ... from live binding is efficient in V8, as they are converted into direct accesses during module compilation.
    • This adds the overhead of one module instantiation/evaluation per ESM loaded via require(), which might matter for performance.

To evaluate the performance impact of each approach, a benchmark was written to measure the overhead. It showed that module loading time was already dominated by resolution/filesystem access/compilation, and the overhead from all the __esModule fixups was negligible by comparison. On export access performance, 1 and 2 had prohibitive overhead (>50%), while 3 and 4 were acceptable (~2–4%). In the end, 4 was chosen for correctness and performance.

To further reduce the overhead, the implementation also caches the facade compilation and only applies the fixup when the provider has a default export (~30% of high‑impact ESM on npm, as found by another script), which is fine since transpilers generally skip the fixup for consumers when there’s no default import.

CommonJS: special "module.exports" string name export

As mentioned earlier, CommonJS and ESM shape exports differently, though for CommonJS modules that don’t reassign module.exports to anything more than an object literal, the difference rarely matters for maintainers migrating packages. But what if it does?

1
2
module.exports = class Klass {};
module.exports.func = function func() {}

The CommonJS consumer of this package could do:

1
2
const { func } = require('package');
const Klass = require('package');

ESM consumers could do:

1
2
3
4
5
6
7
8
9
10
// As long as named exports are assigned in a detectable way, Node.js adds them
// to the namespace.
import { func } from 'package';
// In Node.js, importing CJS yields the module.exports object as the default export
import Klass from 'package';

// Or, if dynamic import is used:
{
const { func, default: Klass2 } = await import('package');
}

To migrate to ESM-only while keeping ESM consumers working, the package needs to export both named and default exports:

1
2
3
4
5
6
class Klass {};
function func() {}
Klass.func = func; // This is a static method

export default Klass; // Keep import Klass from 'package' working
export { func }; // Keep import { func } from 'package' working

But this breaks CommonJS consumers, since the spec places default exports under a default property:

1
2
3
const { func } = require('package'); // Named import still works
// ..but default import is now broken, because it needs unwrapping from the namespace object
const Klass = require('package');

It might be tempting to return the default export from require(esm) here, but that would drop named exports in other cases and lead to surprises. Even restricting it to “no named exports” makes adding one a breaking change for packages — a bit of a footgun.

To work around this, Guy Bedford proposed recognizing a special export that allows ESM providers to customize what should be returned by require(esm). A few proposals were discussed, and after a vote, the chosen name was "module.exports" (this makes use of a less well-known feature of ESM — string literal export names have been allowed since ES6; they’re just uncommon, which happens to help avoid conflicts).

To keep both CommonJS and ESM consumers working, if the original module had module.exports = notAnObjectLiteral, it can just add export { notAnObjectLiteral as "module.exports" } during the migration:

1
2
3
4
5
6
7
8
class Klass {};
function func() {}
Klass.func = func; // This is a static method

export default Klass; // For import Klass from 'package'
export { func }; // For import { func } from 'package'
// and const { func } = require('package')
export { Klass as "module.exports" }; // For const Klass = require('package')

(For packages that do not reassign module.exports in public modules, or only assign it to object literals, this special export is usually unnecessary.)

Dual package: "module-sync" exports condition

The previous post discussed dual packages using "exports" conditions to control which format to load, and how that could lead to the dual package hazard. One pattern to avoid it was to always provide CommonJS on Node.js, even for import:

1
2
3
4
5
6
7
8
9
10
{
"type": "module",
"exports": {
// On Node.js, provide a CJS version of the package transpiled from the original
// ESM version
"node": "./dist/index.cjs",
// On any other environment, use the ESM version.
"default": "./index.js"
}
}

With require(esm), the hope is to eventually simplify to this:

1
2
3
4
{
"type": "module",
"exports": "./index.js" // Provide ESM unconditionally
}

But some packages may still need to support older Node.js for a while. Is there a way to feature‑detect in package.json and point to ESM on newer Node.js, CommonJS on older versions? One straightforward solution would be to add another export condition to supply ESM to both require() and import, and it turned out that bundlers already had something similar — the "module" condition:

1
2
3
4
5
6
7
8
{
"type": "module",
"exports": {
"module": "./index.js", // For bundlers that support require(esm)
"node": "./dist/index.cjs", // For older Node.js versions
"default": "./index.js" // For other environments
}
}

An attempt was made to adopt the same convention in Node.js, but an ecosystem check showed several high‑impact packages already pointed "module" to bundling‑only ESM that can’t run on Node.js. To avoid breaking them, a new condition named "module-sync" was introduced instead:

1
2
3
4
5
6
7
8
9
{
"type": "module",
"exports": {
"module-sync": "./index.js", // For Node.js that supports require(esm)
"module": "./index.js", // For bundlers that support require(esm)
"node": "./dist/index.cjs", // For older Node.js versions
"default": "./index.js" // For other environments
}
}

This adds yet another condition, but it’s only a stop‑gap for packages still supporting older Node.js. By the end of 2025, packages that don’t support EOL Node.js can simply forget about the conditions and point to ESM unconditionally, as shown earlier. "module-sync" now mainly matters for runtimes replicating Node.js resolution (except bundlers, which already had their own "module").

process.getBuiltinModule()

The previous post mentioned that one use case for top-level await was dynamic detection of built-in modules. For example:

1
2
3
4
5
6
try {
// Not sure if it’s Node.js, have to use TLA and dynamic import
const os = await import('node:os'); // Do some tuning with OS info
} catch {
// Not Node.js, fallback to something else
}

This isn’t about top‑level await itself, but the ability to do dynamic built‑in resolution in ESM, and the only viable option to do it back then was via asynchronous import(). This was also brought up by Jake Bailey from the TypeScript team as a blocker for TypeScript to ship ESM at the time. After discussions in TC39’s module harmony group and on GitHub, process.getBuiltinModule() was introduced for synchronous built‑in detection, which helps reduce unnecessary top‑level await in ESM on Node.js:

1
2
3
4
5
6
7
8
9
10
if (globalThis?.process?.getBuiltinModule) {
const os = process.getBuiltinModule('os');
if (os) {
// Do some tuning with OS info
} else {
// Fallback to something else
}
} else {
// Fallback to something else
}

Preventing potential race conditions from a shared cache

Here’s a simplified pseudocode of require(esm) — conceptually straightforward:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function requireESM(specifier) {
// Fetch/link can be made synchronous by the host.
const linkedModule = fetchAndLinkSync(specifier);
if (linkedModule.hasTopLevelAwaitInGraph()) {
throw new ERR_REQUIRE_ASYNC_MODULE;
}
// Without top-level await, the spec guarantees that the
// returned promise either rejects or resolves to undefined *synchronously*.
const promise = linkedModule.evaluate();
const state = getPromiseState(promise);
assert(state === 'fulfilled' || state === 'rejected');

// Using V8 APIs, the exception or result can be extracted synchronously.
if (state === 'rejected') {
throw getPromiseException(promise);
} else {
assert.strictEqual(unwrapPromise(promise), undefined);
}

// Without top-level await, the namespace is populated synchronously.
return linkedModule.getNamespace();
}

In reality, fetchAndLinkSync() turned out to be trickier than it looked. While require(esm) can initiate synchronous fetching/linking, it shares a cache with import, which historically fetched/linked dependencies asynchronously in Node.js. If an ESM is already being fetched/linked asynchronously by import when require(esm) tries to fetch/load it synchronously, races can occur. This is very rare, but it did happen in some cases to tools/frameworks that implemented their own module loading on top of Node.js’s built-in module loader.

The previous fetching/linking routines for import were developed with future asynchronous extensions in mind (async customization hooks, network imports, etc.), but those extensions ran into issues of their own (quirky require() customization for the hooks, security concerns for network imports, etc.) and stayed experimental for years. Since the Node.js ecosystem has been built around synchronous require() plus a separate package manager for over a decade, introducing intrusive changes to that model turned out to be an uphill battle. In the end, those efforts either didn’t pan out or were phased out in favor of a synchronous version. The fetch/link routines were essentially only doing synchronous work and paying the asynchronous overhead/quirks for nothing, so they were simplified to be fully synchronous and aligned with CommonJS loading again. Following that, the races also went away.

Safeguarding ESM evaluation re-entrancy

Nicolo Ribaudo, working on the Deferring Module Evaluation proposal at the time, noticed a spec invariant that could be violated by the early require(esm) implementation: ESM already being evaluated cannot enter evaluation again. Within pure ESM, JS engines can skip re‑entry, but when the cycle crosses ESM/CommonJS boundaries, the re‑entry in a deeper ESM dependency must be blocked by the host:

1
2
// a.mjs
import './b.cjs';
1
2
// b.cjs
require('./a.mjs');
1
2
// c.mjs
import './a.mjs'; // a is already being evaluated; re-entrancy must be blocked.

For the time being, this is safeguarded in Node.js by detecting such cycles and throwing ERR_REQUIRE_CYCLE_MODULE at the first edge that cycles back. The stage-3 Deferring Module Evaluation proposal would introduce a way in the specification to allow skipping synchronous evaluation safely when it’s re-entered, so it would be possible to support such cycles in the future when that gets implemented in V8.

Final thoughts

As noted in the previous post, it took a village to raise require(esm). I hope the new posts shed more light on how a stalled initiative like this can move forward through community collaboration and support. Many thanks to everyone who contributed along the way!