Fixing the Dual Package Hazard (Two Instances Loaded)
A package that ships both ESM and CJS ends up loaded twice — once per format — so instanceof checks fail and singletons desync. This page explains the dual package hazard and how to structure exports so only one instance ever loads.
Exact symptoms and error messages
// import path loads dist/index.mjs
// require path loads dist/index.cjs
assert(a instanceof Base); // false — two Base classes
// module-level singleton state is duplicated
Root cause analysis
When a consumer's graph reaches your package through both import and require, Node resolves each condition to a different physical file and instantiates each independently. Any shared state — a registry, a class identity, a cache — now exists twice. The hazard comes from the conditional exports resolving to two artifacts, a case detailed in ESM and CJS Interoperability.
The hazard is fundamentally about identity, not correctness of either build. Both the ESM and CJS copies may be perfectly correct in isolation; the problem is that a consumer's graph reaches them through different conditions and Node instantiates each as a separate module. Anything that relies on there being exactly one of something — a class used with instanceof, a plugin registry, a cache, a config singleton — now has two, and they do not see each other.
It surfaces most often in mixed ecosystems where an application is ESM but a dependency still requires your package, or vice versa. The two entry points resolve to the two builds, and the doubled state manifests as subtle bugs: a registered plugin that appears missing, a context provider that does not match its consumer, an instanceof that returns false for an object that visibly is that class.
The dual-package hazard is fundamentally about module identity, not the correctness of either build. Both the ESM and CommonJS copies may be individually correct, but when a consumer's dependency graph reaches the package through both import and require, Node resolves each condition to a different physical file and instantiates each as a separate module with its own state. Anything relying on there being exactly one of something — a class used with instanceof, a plugin registry, a cache, a configuration singleton — now has two, and the two do not see each other.
It surfaces most in mixed ecosystems, where an application is ESM but a dependency still requires your package, or the reverse. The two entry points resolve to the two builds, and the doubled state manifests as bugs that resist debugging precisely because each copy is correct in isolation: a registered plugin that appears missing to the other copy, a context provider that does not match its consumer, an instanceof that returns false for an object that visibly is that class. The cause is structural — two builds, two identities — so the fix is structural too.
Resolution and configuration patch
Keep stateful code in a single format and have the other format re-export it, or move shared state behind a format-agnostic module:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
Where a true singleton is required, isolate it in a tiny CommonJS module both builds require, so there is exactly one instance regardless of entry format.
Keep any identity-bearing state in a single module both builds reference, so it is instantiated once regardless of entry format:
// registry.cjs — one instance whichever entry loads it
const registry = new Map();
module.exports = { registry };
Both your ESM and CommonJS builds import or require this same file, so Node instantiates it once and the shared state is genuinely shared. The rest of the package can remain dual-format; only the identity-bearing core needs to be single-format. Where synchronous require support is not needed at all, shipping ESM-only removes the hazard by construction, since a single-format package cannot load twice.
CLI validation and debug commands
# Detect double-loading by logging module init
node -e "require('your-lib'); import('your-lib').then(()=>{})"
# Inspect both resolved files
node -p "require.resolve('your-lib')"
node --input-type=module -e "import.meta.resolve && console.log(await import.meta.resolve('your-lib'))"
Prevention and CI guardrails
- Avoid module-level mutable singletons in dual-published packages.
- Route shared identity through one module both formats reference.
- Document that consumers should not mix
importandrequireof the same package. - Add a test that loads the package both ways and asserts a single instance.
- Avoid module-level mutable singletons in dual-published packages, or isolate them in one shared module.
- Route shared identity (a class, a registry) through a single module both formats reference.
- Document that consumers should not mix
importandrequireof the same package. - Add a test that loads the package both ways and asserts a single instance.
Isolating singletons in a single-format module
The reliable structural fix is to ensure the stateful part of your package has exactly one physical file, loaded the same way regardless of entry format. Move the true singleton — the registry, the shared cache, the class whose identity matters — into a small CommonJS module, and have both your ESM and CJS builds require (or import) that same file.
// registry.cjs — one instance, whichever entry loads it
const registry = new Map();
module.exports = { registry };
Because both builds reference the identical file, Node instantiates it once, and the shared state is genuinely shared. The rest of your package can remain dual-format; only the identity-bearing core needs to be single-format. This is more surgical than forcing the whole package to one format and preserves the dual-publish ergonomics for everything that is stateless.
When shipping ESM-only is the better answer
If your package has stateful singletons and you do not need to support require, the simplest way to eliminate the hazard is to ship ESM only. A single-format package cannot load twice, so the entire class of double-instantiation bugs disappears by construction.
The trade-off is consumer reach: CommonJS-only projects and older tooling cannot require an ESM-only package without a dynamic import() bridge, as covered in Fixing ERR_REQUIRE_ESM in Node.js. For a new library targeting modern runtimes that is an increasingly reasonable trade; for a widely-depended-on package that must run everywhere, the single-format-singleton approach preserves dual publishing while still closing the hazard. Choose based on how much of your audience genuinely still needs a synchronous require.
Testing for a doubled instance
Because the dual-package hazard is invisible until something compares identities, a package that ships dual-format should test for it explicitly rather than hope. The test loads the package both ways — once via import and once via require — in the same process and asserts that a value that should be a singleton is identical across the two, or that an instanceof check holds for an object created by one entry and tested against the class from the other. If the assertion fails, the package is instantiating its state twice and the shared-module fix is needed.
const cjs = require('your-lib');
import('your-lib').then((esm) => {
console.assert(cjs.registry === esm.registry, 'singleton doubled across formats');
});
Running this as part of the package's own test suite turns the hazard from a subtle consumer-reported bug into a caught, local failure. It exercises exactly the condition that triggers the hazard — both entry points loaded in one process — which no ordinary single-format test does. For any dual-published package with stateful singletons, this test is the difference between knowing the shared state is genuinely single and discovering, from a confused consumer, that it was doubled all along.
When ESM-only is the cleaner answer
If a package has stateful singletons and does not need to support synchronous require, shipping ESM-only is the simplest way to eliminate the hazard entirely, because a single-format package cannot load twice by construction. There is no require condition resolving to a separate build, so the whole class of double-instantiation bugs disappears without any shared-module gymnastics. For a new library targeting modern runtimes and bundlers, this is an increasingly reasonable default.
The trade-off is consumer reach: CommonJS-only projects and older tooling cannot require an ESM-only package without a dynamic import() bridge. Whether that cost is acceptable depends on the audience — a library used mainly by modern applications loses little, while a foundational package that must run in every CommonJS codebase may still need the dual build. The decision is between an ESM-only package that trades some reach for structural simplicity and a dual build that preserves reach but requires the single-format-singleton discipline to stay hazard-free. Choosing deliberately, based on how much of your audience genuinely needs synchronous require, is what keeps the package both compatible and correct.
Why the hazard is specific to stateful packages
It is worth being precise about which packages the dual-package hazard actually affects, because most dual-published packages are unaffected and do not need the shared-module discipline. The hazard only bites when a package has state or identity that must be singular — a mutable registry, a cache, a class whose instances are compared with instanceof, a configuration object other code holds a reference to. For a package of pure functions with no shared mutable state, being instantiated twice is entirely harmless: each copy computes the same results, and nothing compares identities across them.
This is why the fix is targeted rather than blanket. You do not need to make an entire package single-format to avoid the hazard; you need to identify the specific identity-bearing pieces — usually a small part of the package — and ensure those live in one module both builds reference. A large utility library can remain fully dual-format if it holds no shared state, while a package built around a singleton registry needs only that registry isolated. Recognizing that the hazard is a property of stateful identity, not of dual publishing in general, keeps the remedy proportionate: isolate the singletons, and let the stateless majority of the package stay dual-format without concern.
Frequently Asked Questions
Does shipping only ESM avoid the hazard?
Yes — a single-format package can only load once. The hazard is specific to dual-published packages reached through both conditions in the same process.
Why do instanceof checks fail across the boundary?
Each format instantiates its own copy of the class, so the two constructors are different identities. An object from the ESM copy is not an instance of the CJS copy's class.
How do I keep a singleton truly single across formats?
Put the stateful code in one small module — commonly a .cjs file — that both your ESM and CJS builds reference. Because both load the identical file, Node instantiates it once and the shared state is genuinely shared.
Does shipping ESM-only fix the hazard?
Yes, by construction — a single-format package cannot load twice. The cost is that CommonJS-only consumers must use a dynamic import() bridge, so weigh it against how much of your audience still needs synchronous require.
Why does instanceof fail across the boundary?
Each format instantiates its own copy of the class, so the two constructors are distinct identities. An object created by the ESM copy is not an instance of the CJS copy's class, even though they came from the same source.
Why does instanceof fail across the ESM/CJS boundary in my package?
Because the dual-package hazard instantiated your package twice — once per format — so each build has its own copy of the class. An object created by the ESM copy is not an instance of the CJS copy's class. Keep the class in one shared module both builds reference, or ship ESM-only.
How do I test for the dual-package hazard?
Load the package both ways in one process — require it and dynamically import it — and assert that a singleton is identical across the two. If the assertion fails, the state is doubled and you need the shared-module fix. Run it as part of your test suite.
Does shipping ESM-only fix the hazard?
Yes, by construction — a single-format package cannot load twice, so double instantiation is impossible. The cost is that CommonJS-only consumers must use a dynamic import() bridge, so weigh it against how much of your audience needs synchronous require.
Does every dual-published package have the dual-package hazard?
No — only packages with shared mutable state or identity that must be singular (a registry, a cache, a class checked with instanceof). A package of pure functions with no shared state is unaffected, since being instantiated twice just computes the same results. Isolate the stateful pieces; the stateless majority can stay dual-format.
Related
- ESM and CJS Interoperability — the module-format rules behind this error.