Over the past few months, we upgraded the /+esm toolchain, worked through the open compatibility reports, and then used production APM data to find the failures that had not been reported yet. This post covers what we found and the changes now running in production.When we introduced jsDelivr’s ESM bundling service, the idea was simple: take a package published to npm and return a browser-ready ES module.
A /+esm request does considerably more than change the module syntax. jsDelivr resolves package exports and browser entry points, converts CommonJS where necessary, provides browser-compatible implementations of supported Node.js APIs, bundles dependencies, removes unused code, minifies the result, and generates a source map. We described many of those capabilities when we announced the service in 2023.
That implementation already worked for most of the npm ecosystem. The packages that remained were not one clear category. They combined generated CommonJS helpers, newer JavaScript syntax, browser aliases, source maps, WebAssembly files, Node.js APIs, and output produced by several generations of build tools.
Before making another round of changes to the bundling pipeline, we wanted to update the backend and its dependencies. Several packages were multiple major versions behind, and some current releases had moved to ESM-only distribution. We converted the backend, its scripts, configuration, tests, and supporting tools to native ESM, then upgraded the dependency stack together.
This included moving from Rollup 2 to Rollup 4 and updating its CommonJS, JSON, and replacement plugins. Rollup 4 itself did not require the backend to be ESM, but the wider dependency upgrade was much easier once the application used the same module system as the growing number of ESM-only packages around it. We also wanted that work finished before adding more substantial fixes and refactorings to versions we already planned to replace.
With the current toolchain in place, we went through the outstanding compatibility reports one by one. After resolving most of the known cases, we used production APM data to inspect the /+esm requests that were still failing, grouped them by cause, and fixed the recurring problems that could be handled safely on our side.
What the dependency upgrade fixed by itself
Some reports were resolved directly by the newer Rollup stack.
JSON import attributes are one example. Packages increasingly use the standardized syntax:
import metadata from './package.json' with { type: 'json' };
A reported failure involving @uppy/core came from this syntax. The previous Rollup 2-based pipeline could not process the published package, while the current Rollup and JSON plugin handle it correctly. We added the syntax to our regression fixtures so that future toolchain changes continue to cover it.
Most of the remaining failures were not solved by upgrading Rollup alone. They came from jsDelivr’s own resolver, CommonJS handling, package transforms, or assumptions that had worked for older package output but no longer covered what was being published.
Modern syntax still depends on package metadata
Top-level await was one such case.
Rollup already supports top-level await in ESM. The failure occurred because some .js package entry points were being passed through CommonJS conversion even when their package declared:
{
"type": "module"
}
For a package marked as ESM, that .js file should be parsed as an ES module from the beginning. Running it through the CommonJS plugin could reject the top-level await before Rollup processed the module normally.
For packages with "type": "module", CommonJS conversion is now limited to .cjs files and .js files inside nested dependencies. The package’s own .js entry points stay on the native ESM path.
Replacing NODE_ENV in more forms
The /+esm pipeline has long replaced Node-style environment checks with a production value. Many packages contain code such as:
if (process.env.NODE_ENV !== 'production') {
enableDevelopmentWarnings();
}
Replacing process.env.NODE_ENV with "production" lets Rollup remove development-only branches and avoids requiring a complete process implementation just for an environment check.
Published packages do not always use that exact expression, however. The GraphQL failure used guarded access through globalThis.process, while other packages used global.process or checked whether process existed before reading from it:
typeof process !== 'undefined' && process.env.NODE_ENV;
global.process && global.process.env.NODE_ENV;
globalThis.process && globalThis.process.env.NODE_ENV;
Those cases are now included in the replacement pass.
A later Vite failure exposed another variation:
process.env['NODE_ENV'];
process.env["NODE_ENV"];
We added the bracket-notation forms as well. At the same time, the matching was narrowed so that it does not replace quoted object keys or unrelated member chains such as:
const definitions = {
"process.env.NODE_ENV": "some literal value"
};
host.process.env.NODE_ENV;
This part of the transform now recognizes the common generated variants without treating every occurrence of the same text as an environment reference.
Two smaller package-input fixes
Some package entry points also serve as command-line programs and begin with a shebang:
#!/usr/bin/env node
That line is useful when the file is executed directly, but it can interfere with later parsing and source-map processing. We replace only the initial #! with //. The replacement has the same length, so line and column positions in an existing source map remain aligned.
We also fixed an edge case in the resolver’s handling of the browser field. jsDelivr uses this field directly when selecting browser-specific files, and some packages contain an explicit self-mapping:
{
"browser": {
"./dist/iife/index.js": "./dist/iife/index.js"
}
}
At that point, the resolver has already reached the intended browser file. Following the mapping again only starts the same lookup over, so self-mappings are treated as resolved and the current file is used.
The long tail of CommonJS exports
The most involved part of the work was CommonJS interoperability.
CommonJS is often summarized as require() plus module.exports, but compiled packages expose their APIs in many different ways. jsDelivr cannot run every package in Node.js to discover those exports. It has to identify them statically and construct an ESM interface before Rollup creates the final bundle.
The AWS SDK report is a good example of how several layers can interact.
@aws-sdk/client-s3 expected the named exports Sha1 and Sha256 from browser-oriented crypto dependencies. Those dependencies did contain the exports, but TypeScript had compiled their re-export files into code resembling:
tslib.__exportStar(require('./implementation'), exports);
There is no direct assignment such as exports.Sha256 = ... in that file. The public names come from another CommonJS module through TypeScript’s generated __exportStar helper.
Our named-export detector now recognizes __exportStar calls whose target is either exports or module.exports, resolves the referenced module, and includes its names in the ESM interface.
Combining CommonJS lexers
That detector also has to handle more ordinary-looking assignments in less ordinary locations.
The @nodefill/primordials report involved exports declared inside conditional branches:
if (condition) {
exports.fromPrimordials = value;
} else {
exports.fallback = require('./fallback');
}
The lexer we were using preserved several object-export and re-export patterns that were important to the existing pipeline, but it did not detect all of the conditional exports.name assignments recognized by Node.js.
Rather than replacing one set of supported patterns with another, the detector now combines the results of Node’s cjs-module-lexer and @esm.sh/cjs-module-lexer. If one parser cannot handle a particular file, the other can still provide useful export information.
CommonJS also allows property names that are not valid JavaScript identifiers:
exports['foo-bar'] = value;
exports['RegExpGet$&'] = anotherValue;
Those names used to be dropped while creating ESM bindings. They are now read from the CommonJS namespace through bracket access and exposed using ESM string-literal re-exports.
CommonJS code importing external ESM
A separate interop problem appeared when converted CommonJS code imported a dependency that jsDelivr had already turned into an external ESM URL.
For example, the resolver may normalize a dependency to:
/npm/package@version/+esm
The CommonJS plugin could still apply CommonJS default-export assumptions to that dependency, even though the target was an ES module. This affected several packages built around ast-types.
We now tell the CommonJS conversion stage to treat these external dependencies as ESM namespaces. Their named exports remain available to the converted module.
The CommonJS plugin also creates virtual proxy modules for external imports. When one of those proxies imported an already-normalized /npm/.../+esm path, Rollup could interpret it relative to the virtual module and produce an invalid URL beginning with ./npm/. Imports coming from these CommonJS external proxies are now explicitly kept absolute.
While working through that path, we removed an older behavior that appended this to ESM bundles containing only named exports:
export default null;
The synthetic default was originally intended as a compatibility convenience, but it did not exist in the source package. In some CommonJS interop paths, the plugin preferred that default over the module namespace, so a dependency with valid named exports could resolve to null.
Named-only packages are now emitted without an invented default export.
Keeping source-relative assets next to their modules
The QuickJS and Box2D-WASM reports looked different in the browser but had the same underlying cause.
Both packages shipped WebAssembly files next to JavaScript modules and located them using import.meta.url:
const wasmUrl = new URL('./module.wasm', import.meta.url);
Inside the published package, this points to a file next to the current module. After bundling, however, the JavaScript is served from a new /+esm URL. If import.meta.url refers to that final URL, the relative .wasm request starts from the wrong directory and returns a 404.
The jsDelivr Rollup plugin now uses Rollup’s resolveImportMeta hook to preserve the original npm URL of each source module. When package code reads import.meta.url, it receives a URL based on the module’s published location rather than only the location of the combined bundle.
The same handling applies to other files loaded relative to a module, including workers, dictionaries, model data, and similar package assets.
Source maps should not prevent valid JavaScript from loading
Published source maps vary considerably in quality.
The pixi-filters failure came from a map containing an unknown source represented as null. That is meaningful as missing information, but Rollup expects source names to be strings. We normalize that form to an unknown source name before passing the map on.
Other maps contain unsupported source values or a non-string sourceRoot. Those maps are ignored rather than being allowed to fail the complete JavaScript bundle.
Production data exposed another case: two source names could normalize to the same path while carrying different sourcesContent values. Rollup cannot combine contradictory source contents for one normalized file, so we discard the unusable input map and continue with the JavaScript.
We also changed how the generated source-map URL is identified. It was previously possible for two transformations to produce the same JavaScript but different source maps. The identifier is now derived from the final serialized map itself, so each distinct map receives its own URL.
Giving tree-shaking a chance to remove Node.js code
Before these changes, the resolver stopped a build as soon as it encountered an unsupported Node.js built-in module such as dgram.
That works for code that genuinely uses dgram in the browser path. It also rejected packages where the import existed only inside a server-specific branch that Rollup could otherwise remove:
if (isNode) {
const dgram = require('dgram');
}
Unsupported built-ins are now represented temporarily as side-effect-free virtual modules. Rollup performs its normal tree-shaking, after which jsDelivr checks whether any of those virtual modules survived in the generated chunk.
If the server-only branch disappeared, the package can be served. If the built-in remains part of the output, the transform still returns the same unsupported-module error.
Expanding the Node.js compatibility layer
Not every Node.js import needs to disappear. Some packages use APIs that can be implemented meaningfully in a browser, and jsDelivr provides those through a fork of rollup-plugin-polyfill-node.
Our production analysis showed several APIs that packages expected but the existing polyfills did not expose, as well as a few implementation and resolution bugs. We made sixteen focused changes to the fork:
util APIs
stripVTControlCharactersutil.typesTextEncoder
URL and path APIs
urlToHttpOptionspathToFileURL- the
path.posixexports path.parse()path.format()
Runtime and built-in APIs
process[Symbol.toStringTag]- coverage and fixes for
os.homedir() timers/promises- broader
cryptosupport - support for
fsandfs/promises
Resolution and behavior fixes
- explicit resolution of the
inheritspackage - corrected internal polyfill resolution
- removal of circular-dependency warnings from the stream implementation
- correct zlib error-code behavior
The fs implementation is a browser-side compatibility layer. It does not provide access to a visitor’s operating system or local files; it supplies the API behavior expected by packages that already have a browser execution path.
Making large transforms less expensive
Compatibility was not the only source of failed /+esm requests. Some packages could be bundled correctly but used too much of the request’s execution window or memory budget.
Rollup already performs module resolution, CommonJS conversion, tree-shaking, and code generation. The final ESM minification step previously ran through Terser, adding another potentially expensive transform after the bundle had been generated.
ESM bundles now use esbuild for that final minification step. We made a similar change to jsDelivr’s general JavaScript minifier: files larger than 4 MiB use esbuild, while smaller files continue through Terser.
Large dependency graphs also revealed repeated metadata requests. A package such as antd may import several files from the same external dependency. Previously, each imported path could trigger another fetch and version resolution for the same package manifest. The manifest is now fetched once per external package and reused for every file imported from it.
Packages that still need another runtime
A substantial part of the remaining failure traffic comes from packages that publish JSX directly in .js files, particularly packages intended for React Native.
Those files are normally processed by Metro, Babel, or another project-specific build pipeline. jsDelivr does not run arbitrary package compiler configurations, and applying one generic JSX transform would not necessarily produce the environment expected by the package.
We now recognize this parse-error pattern and return a clear unsupported JSX message instead of recording it as an unexplained transform crash. Our regression coverage includes @expo/vector-icons.
Packages also continue to fail when an unsupported Node.js built-in remains in the browser bundle after tree-shaking. In practice, most unresolved cases now fall into a few expected groups: server-only packages, React Native packages, and packages whose published files require an application-specific compilation step.
All of the fixes above are covered by regression tests, including the original reported packages. User reports remain the best source of concrete, reproducible examples, while the production data gives us a broader view of the failures that occur often enough to investigate before each one is reported separately.
What this means for /+esm
This work reduced a large part of the remaining compatibility backlog, improved performance for demanding transforms, and made the failures that remain much easier to understand.
There will still be packages that require Node.js, React Native, or an application-specific build step, but the browser-compatible part of the npm ecosystem is now covered more thoroughly – and we have a better foundation for expanding that coverage further.