Avoiding the Dual-Package Hazard in a Published Library

A library that ships both an ESM and a CommonJS build can end up loaded twice in the same app — once as ESM by a modern consumer, once as CJS by a transitive one — and if it holds state, the two copies disagree. This is the dual-package hazard. This guide structures exports and internal state so only one instance ever runs. It applies the ESM vs CommonJS interop mechanics this group covers to a publishing problem; the parent overview is Core Concepts of Modern Bundling.

The problem exists because Node resolves import and require of the same specifier through different conditions in the exports map, and each condition can point at a physically different file. Node keeps two separate module caches keyed by the resolved file path: the ESM loader caches by URL, the CJS loader caches by the resolved filename. When the two conditions resolve to index.js and index.cjs, those are distinct cache keys, so Node instantiates both files. Every top-level binding in each file runs once per file, which means any new Map(), let count = 0, or module-scoped singleton executes twice. Nothing in the resolver notices that both files were transpiled from the same src/index.ts; from Node’s point of view they are unrelated modules that happen to share an API surface.

Where this bites is deep in the dependency graph, not in your own code. Your application might import the library cleanly as ESM, but a transitive dependency three levels down still uses require() — an older plugin, a CJS test helper, a tool that has not migrated. The bundler or Node then loads the CJS build to satisfy that require, and now both builds are live in the same process. If the library’s contract depends on a single shared instance — a plugin registry, a DI container, a WeakMap of framework internals, a “have we initialized yet” flag — the two halves of your app are talking to two different registries and neither can see the other’s writes. The failure is silent: no error, no warning, just state that mysteriously does not propagate. The fix is not to pick one format and drop the other; it is to make both builds re-export their mutable state from a single internal module so that, whichever build loads, they agree on where the state lives.

Two consumers load two builds of one package A modern consumer imports the ESM build while a transitive CommonJS consumer requires the CJS build, so the same package loads twice with separate state; the fix routes both builds' state through one shared internal module. Two entry points, one shared state module import → ESM build require → CJS build state.jssingle source of truth one instanceconsistent state both builds re-export from the same state module, so state cannot fork
Figure: the hazard is two builds with separate state; the fix funnels both through one shared state module.

Prerequisites & reproducible setup

# tsup 8.3.x (esbuild + dts), Node 20+ — a dual-format library
mkdir dual-lib && cd dual-lib && npm init -y
npm install --save-dev tsup
# The library holds state: a registry that must be a singleton.

A library that exports pure functions has no hazard — two copies compute the same result. The hazard only bites when the package holds mutable state (a registry, a cache, a plugin list) that two copies would fork.

Before writing any config, be honest about whether your package is in the danger zone. Run through the ownership question: does anything survive between two calls into your API? A memoization cache, an incrementing id counter, a Set of registered plugins, an event-emitter’s listener list, a Symbol used as a private-field key, or a class whose identity is checked with instanceof — all of these are per-copy state. If two loads of the library produce two of them, consumers that reach the “wrong” one see stale or empty data. If your package is genuinely stateless — string formatters, math helpers, type guards — you can ship dual formats with no special care, and the rest of this guide is insurance you do not need to buy. The reason to still structure things correctly is that stateless libraries grow state over time; a caching layer added in a minor version can introduce the hazard into a package that never had it, so the single-state-module discipline is cheap to adopt early and expensive to retrofit once consumers depend on the split layout.

Only stateful packages are exposed to the hazard A library of pure functions is unaffected because two copies compute identical results, but a library holding mutable state such as a registry or cache forks that state when loaded twice, which is the condition the rest of the guide addresses. pure functionstwo copies agree — safe mutable stateregistry / cache — forks state is the precondition for the hazard
Figure: the hazard needs state — a pure package is immune no matter how it is loaded.

Diagnosis workflow

  1. Determine whether the package holds state. If it exposes a registry, singleton, or module-level cache, it is at risk; pure utilities are not. Grep the source for module-scoped new Map, new Set, new WeakMap, top-level let/var, and any export const bound to a mutable object — those are the values that fork. A quick confirmation from the built artifacts: grep -n "new Map\|new Set" dist/index.js dist/index.cjs; if the same allocation appears in both files, each build owns its own copy and the state can split.
  2. Check how consumers load it. A mixed graph where some code imports and some requires the package is the condition for loading both builds. Trace it with npm ls dual-lib to see every dependent, then check which of them are CJS (no "type": "module", .cjs entry, or a require('dual-lib') in their source). One transitive CJS dependent is enough to pull in the CJS build alongside your ESM import; the hazard does not require your own code to use both formats.
  3. Inspect the exports map. The import/require conditions must point at builds that share the same underlying state module, not two independent bundles that each embed their own copy. Run node --conditions=import -e "console.log(require.resolve('dual-lib'))" and the same with --conditions=require to see exactly which file each condition resolves to, then confirm both were emitted from the same source graph rather than two independent tsup/rollup runs that each inlined the state.
Only stateful packages in a mixed graph are at risk The hazard requires two conditions together: the package holds mutable state, and the consumer graph loads it both as ESM and as CommonJS; a pure package or a single-format graph is safe. stateful + mixed loadboth conditions truehazard ✗ pure OR single-formateither condition falsesafe ✓
Figure: it takes both state and a mixed load graph — remove either and the hazard disappears.

Annotated solution config

The whole fix rests on one invariant: there is exactly one file in your published package that allocates the mutable state, and every other file — in both formats — reaches that state by import rather than by re-allocation. The exports map decides which physical file each condition resolves to, the source layout decides where state is allocated, and the build config decides whether the compiler is allowed to duplicate it. All three have to agree. Below, state.ts is the single owner, index.ts re-exports from it, the exports map routes both conditions at builds compiled from that same graph, and tsup is told not to split the state into a per-format chunk.

// package.json — conditional exports pointing at builds that share state.
{
  "name": "dual-lib",
  "type": "module",
  "main": "./dist/index.cjs",       // legacy require() fallback
  "module": "./dist/index.js",      // ESM
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",   // import → ESM build
      "require": "./dist/index.cjs"  // require → CJS build
    }
  }
}

The main and module fields are legacy fallbacks for resolvers that do not understand exports; keep them, but understand that modern Node and every current bundler read the exports map first and ignore main/module entirely when a matching condition is present. The condition keys are matched top to bottom, so types sits first (TypeScript stops at the first match), and import/require follow. Do not add a bare "default" that points at only one format above the import/require split, or one condition will win for both loaders and you will silently ship a single format.

// src/state.ts — the ONE module that owns state. Both builds re-export it.
export const registry = new Map<string, unknown>();

// src/index.ts — public API re-exports from the single state module.
export { registry } from './state.js';
export function register(k: string, v: unknown) { registry.set(k, v); }
// tsup.config.ts — emit both formats from the same source graph.
import { defineConfig } from 'tsup';
export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],   // both builds compiled from one source
  dts: true,
  splitting: false,          // keep state in one chunk, not duplicated
});

splitting: false is load-bearing, not cosmetic. With splitting on, tsup (via esbuild) may hoist state.ts into a shared chunk for one format but inline it into the entry for another, and the two formats then disagree about where the Map lives even though they came from one source. Turning splitting off forces the state module to compile into exactly one place per format, and because both formats re-export the same symbol, the runtime graph collapses to a single allocation per loaded build. Emitting both formats from one tsup invocation also matters: two separate build runs, even from identical source, produce artifacts that each embed their own state, which is exactly the fork you are trying to prevent.

How it works under the hood

Node’s resolver walks the exports map with an ordered set of active conditions. For an ESM import, the condition list includes import and default but not require; for a CJS require, it includes require and default but not import. The first key in the object that is present in the active set wins, and resolution stops there — this is why key order is a correctness concern, not a style choice. Once a file is resolved, it is instantiated and cached: the ESM loader caches the module namespace against the resolved URL, the CJS loader caches module.exports against the resolved filename. Because index.js and index.cjs are different paths, they occupy different cache slots and both run their top-level code. The single-state-module pattern works within this constraint rather than against it: both index.js and index.cjs still load and still run, but each one’s top-level code merely re-exports a binding that traces back to one state module, so the Map is allocated once and both entry points hand out references to it. You are not preventing the double load — that is baked into how Node caches — you are making the double load harmless by removing the duplicated allocation.

Bundlers cannot rescue you here automatically because the ESM and CJS graphs are resolved by different machinery and are not deduplicated against each other. A bundler that pulls in your ESM build and, elsewhere, your CJS build treats them as two unrelated modules with different resolved ids; there is no key it could match on to know they are “the same” library, so it emits both. That is why the fix has to live in how the package is authored and published, not in the consumer’s bundler settings.

Both formats compile from one source that owns state once tsup compiles the ESM and CJS builds from the same source graph in which a single state module owns the mutable state, and the exports map routes import and require to those two builds, so the state definition is shared rather than duplicated. one source graphstate.ts owns state import → index.js require → index.cjs two entry points, one state definition
Figure: both entry points trace back to one source graph, so there is a single state definition to load.

Verification

# Node 20+ — load the package both ways and confirm shared state.
node --input-type=module -e "
  import('dual-lib').then(async (esm) => {
    const cjs = require('dual-lib');
    esm.register('a', 1);
    console.log(cjs.registry.get('a')); // expect 1 if state is shared
  });
"

If the ESM and CJS entry points share state, registering through one is visible through the other. If they load separate copies, the require side sees undefined — the dual-package hazard in action, and a signal your builds do not share the state module. The cross-entry write is the definitive test because it exercises the exact failure mode consumers hit: a value produced by one format has to be observable through the other. Assertions about the shape of the API, the presence of both files, or matching version numbers do not catch a forked Map; only a write-here-read-there check does.

This one-off script is easy to forget, so wire it into the package’s test suite where it runs on every change. The version below fails loudly with a non-zero exit, which is what you want in CI — a regression that reintroduces the fork (someone flips splitting back on, or adds a second build step) should break the build, not ship silently.

// dual-package.test.mjs — Node 20+ built-in test runner, no deps.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

test('ESM and CJS entries share one state instance', async () => {
  const esm = await import('dual-lib');       // resolves the import condition
  const cjs = require('dual-lib');            // resolves the require condition
  esm.register('probe', 42);
  // If both entries reference the same state module, the write is visible here.
  assert.equal(cjs.registry.get('probe'), 42, 'state forked: two copies loaded');
});

CI integration

Run the check on the published artifact, not on your source tree, or you will test a shape you never ship. In practice that means npm pack, install the tarball into a scratch directory, and run the test against the installed package so the exports map is exercised through real resolution. A minimal job — npm pack && npm i --prefix /tmp/verify ./dual-lib-*.tgz && node --test dual-package.test.mjs — catches the two most common regressions: an exports edit that misroutes a condition, and a build-config change that re-duplicates the state chunk. Gate publishing on it via a prepublishOnly script so a forked build can never reach the registry.

A write through one entry visible through the other The verification writes to the registry through the ESM entry and reads it back through the CJS entry; seeing the value confirms shared state, while undefined confirms two separate copies loaded. write ESM, read CJS → 1shared state ✓ read CJS → undefinedtwo copies ✗ a cross-entry write is the definitive test
Figure: a value written through one entry and read through the other is the unambiguous proof of a single instance.

Gotchas & edge cases

Four dual-package edge cases An exports map order that puts require before types can misresolve; code splitting can duplicate the state chunk across formats; a consumer using both import and require of the same package still risks two copies; and instanceof checks fail across the ESM and CJS boundary. exports order→ types first, then import/require splitting duplicates state→ splitting: false mixed import + require→ pick one in the app instanceof across boundary→ compare by a stable key
Figure: splitting: false matters — code splitting can silently re-duplicate the very state module you unified.
  • exports condition order. types must come first, then import/require; a misordered map can resolve the wrong build. The symptom is a TypeScript build that picks up the wrong declaration file, or a loader that serves CJS to an ESM importer. Because condition matching stops at the first hit, a require key placed above types will satisfy an unrelated tool and short-circuit resolution. Confirm the fix with node --conditions=import --conditions=types -e "require.resolve('dual-lib')" style probes, or use a tool like @arethetypeswrong/cli which flags misordered and masked conditions directly.
  • Splitting duplicates state. With code splitting on, the state module can be duplicated per format; set splitting: false so it stays a single chunk. The symptom is that verification passes within one format but the cross-entry write returns undefined, because splitting inlined state.ts into one build while hoisting it in the other. The root cause is that esbuild’s chunking is per-format and does not coordinate across the ESM and CJS outputs. Confirm by grepping the built files: the new Map() allocation should appear in exactly one location that the other files import, not repeated inline in each entry.
  • Mixed loading in one app. Even a correct package loads twice if your own app both imports and requires it; standardize on one in your code. The symptom is a singleton that resets or a registry that appears empty in half the app despite being populated in the other half. The root cause is that your own module graph pins both conditions live at once, which no package-side fix can prevent. Confirm with npm ls dual-lib and a grep for require('dual-lib') across your source; migrate the stragglers to import so only one condition ever resolves.
  • instanceof across the boundary. A class instance from the ESM build is not instanceof the CJS build’s class; compare by a stable property, not identity. Even with a shared state module, if two builds each define the class rather than importing it, their prototypes differ and identity checks fail. The symptom is a type guard that returns false for an object that is obviously the right shape. The fix is the same discipline applied to classes: define each class in one internal module and re-export it, or, where you cannot control the load, tag instances with a stable string or Symbol.for() key and compare that instead of relying on prototype identity.

When not to use this

Dual-format publishing is worth the ceremony only while a meaningful slice of your consumers are still CJS. If your audience is entirely on ESM — a modern app-only library, an internal package in an all-ESM monorepo — drop the CJS build entirely and ship ESM-only. One format cannot fork against itself, so the hazard is gone by construction and you delete an entire build target, a set of exports conditions, and this whole class of bug. The opposite simplification also removes the hazard: if you must support CJS consumers and your state model is hard to keep single-instance, ship CJS-only and let ESM consumers load it through Node’s interop, accepting the named-export limitations that entails. The single-state-module pattern is the right answer specifically when you cannot drop either format; it is not a default to reach for when one format would do.