Resolving Dual Package Hazard in ESM and CJS Builds
The bug is always about identity. A validation library throws on an object it created moments earlier; a state container’s singleton has two independent stores; a Symbol registry finds nothing it registered. In the analyzer treemap, the cause is plainly visible:
node_modules/@acme/schema/dist/index.mjs 28.4 kB
node_modules/@acme/schema/dist/index.cjs 31.1 kB ← the same library, again
One package, two builds, both in the bundle. Every module-scoped value inside it exists twice, and any two consumers that happened to resolve different entries disagree about everything.
This is the failure mode the migration in converting CJS libraries to ESM for better bundling is designed to end, encountered halfway through — when a package publishes both formats and the graph resolves both.
Root cause: two entry points, two module registries
A dual-published package declares several entries in its manifest:
{
"name": "@acme/schema",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.cjs"
}
}
}Which file a given importer receives depends on how it imports. An ESM import matches the import condition; a transitive dependency still written in CommonJS matches require. Both are correct resolutions, and the bundler honours both — producing two module instances that share nothing.
The consequences follow directly from module scope. Classes are distinct, so instanceof fails. Singletons initialise twice. Registries and caches diverge. Symbols created with Symbol() rather than Symbol.for() never match. None of these produce a clear error message; they produce behaviour that looks like a logic bug in your code.
Fix 1: force one resolution with an alias
For a browser bundle, the simplest and most reliable fix is to make every importer resolve the same file regardless of how it asked.
// vite.config.js — Vite 5+
import { fileURLToPath } from 'node:url';
export default {
resolve: {
alias: {
// Every importer — ESM or CJS, yours or transitive — lands here.
'@acme/schema': fileURLToPath(
new URL('./node_modules/@acme/schema/dist/index.mjs', import.meta.url)
),
},
// Prefer real ESM entries generally, so this is the exception not the rule.
mainFields: ['module', 'browser', 'main'],
},
};// webpack.config.js — Webpack 5
const path = require('path');
module.exports = {
resolve: {
alias: {
'@acme/schema$': path.resolve(__dirname, 'node_modules/@acme/schema/dist/index.mjs'),
},
// Condition order decides which entry wins when no alias applies.
conditionNames: ['import', 'module', 'browser', 'default'],
mainFields: ['module', 'browser', 'main'],
},
};The $ in the Webpack alias is deliberate: it matches the bare specifier exactly, so deep imports into the package’s subpaths still resolve normally.
Fix 2: align condition order instead of aliasing
Aliasing pins one file, which is decisive for a browser build and awkward for code that also runs in Node, where a runtime require can still reach the CommonJS copy. The more portable fix is to make the condition order consistent everywhere the graph is resolved.
{
"imports": {
"#schema": {
"import": "@acme/schema/dist/index.mjs",
"require": "@acme/schema/dist/index.mjs"
}
}
}Mapping both conditions to the same file inside your own package manifest means every internal importer converges, whichever syntax it uses. Consumers with their own resolution can still diverge, which is why this pairs well with the alias rather than replacing it.
Fix 3: deduplicate at install time
Sometimes the two copies are not two formats but two versions, resolved into separate directories by the package manager. That is a different problem with a similar symptom, and it is worth ruling out first — the detection procedure is in finding duplicate dependencies in a bundle.
{
"resolutions": {
"@acme/schema": "4.2.1"
},
"overrides": {
"@acme/schema": "4.2.1"
}
}Forcing a single version collapses the directory duplication; the format duplication still needs one of the earlier fixes.
Step-by-step verification
-
Search the built output for both entries. Grep for a distinctive string from the package. Two occurrences in different chunk positions means both formats shipped.
-
Assert single-instance at runtime. Have two separate modules import a module-scoped value from the package and compare by identity. Inequality proves duplication regardless of what the bundle looks like.
-
Trace the second importer. Use the bundler’s module reasons output to find what pulled the second copy — usually a transitive CommonJS dependency.
-
Confirm deep imports still resolve. After aliasing the bare specifier, verify that subpath imports into the package have not broken.
-
Test the Node path separately. If the code also runs server-side, confirm one instance there too; a browser-only alias does not constrain Node’s own resolver.
-
Re-measure the bundle. Removing the duplicate should reduce the bundle by roughly the package’s full size — if it does not, the second copy is still present under a different path.
Edge cases and gotchas
Peer dependencies. A package expecting a shared peer can end up with its own copy if the peer resolves differently for it. Check peer resolution explicitly when identity checks fail across a package boundary.
Framework packages. Duplicating a framework produces the same class of failure with much louder symptoms — the invalid-hook-call family described in fixing shared dependency version conflicts in Module Federation.
Browser field interactions. A package declaring both browser and exports can resolve differently depending on which the bundler prefers. Set the condition order explicitly rather than relying on defaults.
Test environments. A test runner with its own resolution can load a different entry than the production build, so a duplication bug can pass every test and fail in production, or the reverse.
FAQ
Why does instanceof fail against a class from a dual-published package?
Because the two copies define two distinct classes that happen to share a name. An object created by the ESM copy’s constructor has that copy’s prototype in its chain, and a check against the CommonJS copy’s class compares against a different prototype object, so it returns false. The code is correct in both places; the identity assumption underneath it is not, because there are two identities.
Is a package shipping both ESM and CommonJS a mistake?
No — it is how a library serves both bundlers and Node runtimes, and it is usually the right thing for a package author to do. The hazard is not dual publishing itself but dual loading: an application graph in which some importers resolve the ESM entry and others the CommonJS one. Packages can reduce the risk by keeping all state in a shared internal module, but the resolution decision ultimately belongs to whoever builds the application.
Does aliasing to the ESM build always work?
Almost always for a browser bundle, and not always for code that also runs in Node. Forcing the ESM entry gives better tree-shaking and one instance, but a dependency that calls require on the package at runtime can still reach the CommonJS copy in a Node environment. For server-side builds, align the condition order instead of aliasing, so one entry wins consistently in both directions.
Related
- Converting CJS Libraries to ESM for Better Bundling — the parent guide on module format migration
- Fixing Tree-Shaking Failures With Webpack 5 — the sibling failure when a CommonJS entry wins
- Understanding ES Modules vs CommonJS in Bundlers — how conditions and entry fields are resolved
- Finding Duplicate Dependencies in a Bundle — ruling out version duplication first