Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

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

Exact symptoms and error messages Exact symptoms and error messages in production JavaScript package workflows. Exact symptoms and error messages Exact symptoms and error messages in production JavaScript package workflows.
Exact symptoms and error messages — the core idea of this section at a glance.
// 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.

Root cause analysis When a consumer's graph reaches your package through both import and require, Node resolves each condition to a differen 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 in
Root cause analysis — the core idea of this section at a glance.

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.

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:

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-agnos 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:
Resolution and configuration patch — the core idea of this section at a glance.
{
  "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.

CLI validation and debug commands

CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows. CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows.
CLI validation and debug commands — the core idea of this section at a glance.
# 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 import and require of the same package.
  • Add a test that loads the package both ways and asserts a single instance.
Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows. Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows.
Prevention and CI guardrails — the core idea of this section at a glance.

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.

Single-format core Both builds reference one stateful module for one instance. stateful core one .cjs module both builds require it same file one instance shared state
Keeping the identity-bearing core single-format guarantees exactly one instance.
// 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.

Format choice Whether to ship dual-format or ESM-only. Do you need synchronous require support? no ship ESM-only yes, widely single-format singleton legacy audience dual + bridge
ESM-only removes the hazard by construction; weigh it against consumer reach.

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.

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.

Related

ESM and CJS Interoperability