How to Fix Dual Package Hazard in a Library
This walkthrough extends Modern Module Formats: ESM vs CommonJS inside JavaScript Bundle Optimization & Code Splitting. The symptom is bizarre until you know the cause: a context provider set at the top of the app reads as undefined in a component, or a registry populated at startup is empty at use, or the analyzer shows one library counted twice at the same version.
Both halves of your application are correct. They are simply talking to two different copies of the same module — one loaded through the package's ESM entry, one through its CommonJS entry.
Rapid Diagnosis: Prove There Are Two Instances
- Add an identity check. Instantiate a module-level marker and assert there is only one; two proves the duplication immediately.
- Look for the same version twice in the bundle report. Paths ending in
dist/esm/...anddist/cjs/...for the same package are the giveaway. - Check which consumers import which way. A dependency that still uses
require()will take the CommonJS branch while your app takes the ESM one. - Test the failing behaviour in isolation. If a context works when both consumer and provider live in the same file, the problem is module identity, not your logic.
Root Cause Analysis
1. Two entry fields resolved by different consumers. main points at CommonJS, module at ESM. A bundler following module and a dependency calling require() end up in different files, and module identity is per file.
2. An exports map with inconsistent conditions. If import and require resolve to different builds of the same source, any mixed graph loads both. This is correct for Node interop and hazardous for anything holding state.
3. Module-level state in the library. The hazard only hurts when the library owns something singular: a context, a registry, a store, an event bus, a WeakMap keyed on identity. A pure utility library duplicates bytes and nothing else.
4. A transitive dependency stuck on CommonJS. You may have converted your own imports, but one dependency still requires the package, which is enough to pull the second copy in.
Step-by-Step Resolution
1. Prove the duplication before changing packaging.
// In the library itself, during development.
const INSTANCE = Symbol.for('@acme/store.instance');
if (globalThis[INSTANCE]) {
console.error('[@acme/store] duplicate instance detected — dual package hazard');
}
globalThis[INSTANCE] = true;
// trade-off: a Symbol.for registry key is global and permanent, so it is a
// diagnostic, not a fix. Keep it behind a development-only flag so production
// bundles do not carry it.
2. Give both conditions the same underlying module where state lives. The safest packaging keeps one implementation and makes the CommonJS entry a thin wrapper around it.
{
"name": "@acme/store",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"sideEffects": false
}
// dist/index.cjs is generated as a re-export shim, not a second implementation.
// Its module-level state must come FROM the ESM build, not be redeclared.
// trade-off: a shim cannot re-export live bindings to CommonJS consumers
// perfectly. For a library whose whole purpose is shared state, shipping ESM
// only is cleaner than maintaining a correct interop shim.
Expected outcome: state lives in exactly one module regardless of which condition a consumer resolves.
3. Consider shipping ESM only. For a library with singleton semantics, the dual build is the problem. Every current bundler and Node version supports ESM, and the interop burden moves to the small number of consumers that still need it.
4. Deduplicate at the application level as a stopgap. If you do not control the library, force one resolution in your build.
// vite: one physical path wins, whichever entry the graph asks for.
export default {
resolve: {
alias: [{ find: /^@acme\/store$/, replacement: '/node_modules/@acme/store/dist/index.js' }],
dedupe: ['@acme/store']
}
};
// trade-off: forcing the ESM build on a consumer that expected CommonJS can
// break it at runtime if it relies on require-time semantics. Test the
// dependency that was requiring it, not only your own code.
Verification
Run the identity check in a production build with the real dependency graph — a development server can resolve differently and hide the problem. One instance is a pass; two means something in the graph still takes the other branch.
Then confirm in the bundle report that the package appears exactly once, and check the byte saving: removing a duplicate typically recovers the full size of the smaller build.
Add a guard so a future dependency cannot reintroduce it:
# Fail the build if both entry variants of a singleton package are emitted.
node -e '
const s = require("./dist/stats.json");
const hits = Object.keys(s.modules ?? {}).filter((m) => m.includes("@acme/store"));
const variants = new Set(hits.map((m) => (m.includes("cjs") ? "cjs" : "esm")));
if (variants.size > 1) { console.error("dual package hazard:", hits); process.exit(1); }
'
# trade-off: this pattern-matches on paths, so a package that names its builds
# differently slips through. Keep the runtime identity assertion as the real
# safety net and treat this as an early warning.
FAQ
Is dual package hazard always a problem?
No. For a stateless utility library, two copies cost bytes and nothing else — annoying but not incorrect. It becomes a genuine bug the moment the library owns something singular: a React context, a store, a plugin registry, an event emitter, or a WeakMap keyed on module identity. Those are the packages worth auditing first.
Should new libraries ship CommonJS at all?
Increasingly, no. Every maintained bundler and Node release supports ESM, and shipping a single format removes this entire failure class along with half the packaging configuration. Ship CommonJS only when you have identified consumers who genuinely cannot consume ESM, and treat it as a compatibility shim rather than a co-equal build.
Why does my React context work in dev but not in production?
Because the dev server and the production bundler resolve conditions differently — dev servers often prefer the ESM branch uniformly, while the production build follows the full graph including a dependency that requires the CommonJS one. The result is two createContext calls in production and one in development, so the provider and the consumer look at different contexts.
Which packages should I audit first?
Anything that owns identity or state: React and its renderer, routers, state stores, dependency-injection containers, i18n instances, plugin registries, and any library documented as requiring a single instance. Those are the ones where duplication is a functional bug rather than a byte cost. A quick pass is to search your dependency list for packages whose README mentions "singleton", "provider" or "must only be loaded once" — that phrasing is a reliable marker.
Do bundler-level dedupe options solve this permanently?
They solve it for your build, not for your consumers. An alias forces one physical path in the application you control, which is exactly the right stopgap, but a library shipping ambiguous entry points will keep breaking every other consumer in the same way. If you own the package, fix the packaging; if you do not, alias locally and open an issue upstream with the identity assertion as evidence.
How does this interact with peer dependencies?
They address different halves of the same requirement. A peer dependency ensures there is one installed copy by making the consumer supply it; a correct exports map ensures that copy resolves to one module instance whichever way it is imported. A package can get the peer declaration right and still duplicate itself through a dual build, so a stateful library needs both.
Related
- Modern Module Formats: ESM vs CommonJS — how the two systems differ and why interop is hard.
- Finding duplicate dependencies in a monorepo bundle — the version-range cause of the same symptom.
- Tree Shaking and Dead Code Elimination — the other place packaging fields change what ships.