require(esm) in Node.js: implementer's tales

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.

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

{
"name": "faux-esm-package",
"main": "dist/index.js",
"module": "src/index.js"
}

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

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'));




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

;
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

import Klass, { func } from 'faux-esm-package';
import Klass2 from 'commonjs-package';
1
2
3
4
5
6
7
8
9
10
11
12
13
14

;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};


const faux_esm_package_1 = __importDefault(require("faux-esm-package"));
const Klass = faux_esm_package_1.default;
const func = faux_esm_package_1.func;


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


const faux_esm_package_1 = __importDefault(require("faux-esm-package"));
const Klass = faux_esm_package_1.default;
const func = faux_esm_package_1.default.func;

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


import { func } from 'package';

import Klass from 'package';


{
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;

export default Klass;
export { func };

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

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

const Klass = require('package');

It might be tempting to return the default export from require(esm) here, but that would lead to a loss of named exports in other cases where they are not assigned as properties to the default export. Even restricting it to the case without 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;

export default Klass;
export { func };

export { Klass as "module.exports" };

(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": {


"node": "./dist/index.cjs",

"default": "./index.js"
}
}

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

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

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",
"node": "./dist/index.cjs",
"default": "./index.js"
}
}

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",
"module": "./index.js",
"node": "./dist/index.cjs",
"default": "./index.js"
}
}

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 {

const os = await import('node:os');
} catch {

}

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) {

} else {

}
} else {

}

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) {

const linkedModule = fetchAndLinkSync(specifier);
if (linkedModule.hasTopLevelAwaitInGraph()) {
throw new ERR_REQUIRE_ASYNC_MODULE;
}


const promise = linkedModule.evaluate();
const state = getPromiseState(promise);
assert(state === 'fulfilled' || state === 'rejected');


if (state === 'rejected') {
throw getPromiseException(promise);
} else {
assert.strictEqual(unwrapPromise(promise), undefined);
}


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:

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!

trang chủ - Wiki
Copyright © 2011-2026 iteam. Current version is 2.148.3. UTC+08:00, 2026-01-17 20:40
浙ICP备14020137号-1 $bản đồ khách truy cập$