Navigating the Dual-Package Hazard
Prevent singleton state corruption when ESM and CJS load the same package twice. Fix exports field ordering, centralize shared state, and automate CI validation.
When a Node.js process loads your package twice — once through require() and once through import — two completely separate module instances exist in memory simultaneously. Singleton patterns silently break: class identity checks like instanceof return false across the boundary, event emitters registered in one instance never fire listeners attached in the other, and in-memory caches diverge. This problem surfaces in Node.js 12+ wherever a package ships both an ESM and a CJS artifact without a properly ordered exports field, and it becomes especially damaging in frameworks that mix consumer code (ESM) with legacy plugins (CJS).
Prerequisites
Before working through the steps below, confirm:
The Mechanics: Why Two Instances Appear
For full background on why the two module systems maintain separate caches, see Module System Fundamentals & Dual-Package Resolution.
Node.js keeps require.cache for CommonJS and a separate internal ESM registry. The two caches never merge. If your package ships both dist/cjs/index.cjs and dist/esm/index.js, and one part of a consumer’s dependency graph calls require('your-lib') while another calls import 'your-lib', both files are evaluated independently and two separate instances live in memory for the lifetime of the process.
The diagram below shows exactly where the split occurs:
To confirm a hazard in a running process, compare resolution paths across both loaders:
// hazard-inspect.mts
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const pkg = 'your-dependency';
const cjsResolved = require.resolve(pkg);
const cjsCached = Object.keys(require.cache).filter(p => p.includes(pkg));
console.log('CJS resolved path:', cjsResolved);
console.log('CJS cache entries:', cjsCached.length);
// If the ESM path (import.meta.resolve) differs → the hazard is active.
console.log('ESM resolved path:', import.meta.resolve(pkg));
Canonical Configuration: Exports Map That Prevents the Split
The single most effective mitigation is an exports map whose condition ordering makes both import and require resolve to the same underlying file, or a thin CJS wrapper that delegates to one canonical ESM implementation. When both loaders reach the same file, Node.js deduplications it and only one instance is created.
{
"name": "your-library",
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
},
"default": "./dist/esm/index.js"
}
}
}
Key ordering rules:
"types"must come before"default"inside every condition block — TypeScript’s resolver reads the first matching entry."import"must come before"require"— Node.js conditions are evaluated top-to-bottom and stops at the first match."default"sits at the end of the outer object as the final fallback for tools that do not negotiate conditions.
Step-by-Step Implementation
Step 1 — Detect whether both formats are currently loaded
Run the inspection script above during your integration test. If cjsResolved and the ESM path differ, two instances will coexist. You can also use the --require / --import flags to force-load both entry points and observe whether your singleton state diverges:
node \
--require ./dist/cjs/index.cjs \
--import ./dist/esm/index.js \
test/singleton-integrity.mts
Expected output when the hazard is not present:
CJS resolved path: /your-project/node_modules/your-dep/dist/cjs/index.cjs
ESM resolved path: /your-project/node_modules/your-dep/dist/cjs/index.cjs
# Both resolve to the same file — single instance confirmed.
Step 2 — Fix the exports field ordering
Copy the canonical block above into your package.json. If your build tool generates an exports map automatically (tsup, unbuild, Rollup), cross-check its output — some tools still emit "require" before "import".
Verify with publint:
npx publint
Expected pass output:
✔ No issues found
Common failure output before the fix:
⚠ "exports['.']['require']" is listed before "import" — ESM consumers may receive the CJS build
Step 3 — Align TypeScript’s resolver with Node.js
Mismatched moduleResolution settings cause TypeScript to resolve the wrong entry point, hiding condition ordering bugs until runtime. Set both module and moduleResolution together — they must agree:
{
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"strict": true
}
}
For path mapping and module resolution strategies that rely on path aliases, the Node16/Bundler pairing also controls how paths and baseUrl interact with exports — keep them aligned.
Run tsc --noEmit and attw together:
npx tsc --noEmit && npx attw --pack .
attw sample pass output:
✔ No problems found with types
Step 4 — Centralise shared state through globalThis when dual-loading cannot be eliminated
When a third-party dependency you cannot control ships both formats, fix the exports map on your side (via a Vite alias or esbuild --conditions), or fall back to globalThis as a shared namespace across the module boundary:
// src/state-registry.ts
type Registry = typeof globalThis & {
__YOUR_LIB_STATE__?: Map<string, unknown>;
};
const g = globalThis as Registry;
export function getSharedState<T>(key: string, factory: () => T): T {
if (!g.__YOUR_LIB_STATE__) {
g.__YOUR_LIB_STATE__ = new Map();
}
if (!g.__YOUR_LIB_STATE__.has(key)) {
g.__YOUR_LIB_STATE__.set(key, factory());
}
return g.__YOUR_LIB_STATE__.get(key) as T;
}
Use a unique, prefixed key to avoid collisions with other libraries using the same pattern.
HAZARD PREVENTION —
globalThisbridging is a last resort. It introduces global mutable state that makes testing harder and is invisible to bundler tree-shaking. Fix theexportsmap first; reach forglobalThisonly when you do not control the dependency’s published package.
Step 5 — Force single-format resolution at the bundler level
When a bundler misreads condition ordering, override resolution for the specific package:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
alias: {
'your-dependency': 'your-dependency/dist/esm/index.js',
},
conditions: ['import', 'module', 'browser', 'default'],
},
});
# esbuild: pin conditions so CJS fallback never fires
npx esbuild src/index.ts \
--bundle \
--platform=node \
--conditions=import,module \
--outfile=dist/bundle.js
For legacy CJS environments that cannot use import, a dynamic-import shim avoids the synchronous require() / ESM boundary without creating a second instance:
// src/cjs-compat-shim.cts
module.exports = async function loadDependency() {
const esmModule = await import('your-dependency');
return esmModule.default ?? esmModule;
};
Hazard Call-Outs
HAZARD PREVENTION —
instanceofreturningfalseacross the boundary When two module instances load the same class definition, each has a distinct prototype chain.foo instanceof MyClassreturnsfalseeven whenfoowas constructed with what appears to be the same class. Root cause: theMyClassreference in the ESM instance and theMyClassreference in the CJS instance are different objects. Fix: ensure only one instance loads (correctexportsordering) or use duck-typing ('method' in foo) instead ofinstanceofin cross-boundary code.
HAZARD PREVENTION — Event emitter listeners silently dropped
emitter.on('event', handler)called in CJS code registers on Instance B’s emitter.emitter.emit('event')called in ESM code fires Instance A’s emitter. No listeners ever run. Fix: the emitter object itself must be shared — either export it from a single canonical instance or route it throughglobalThis.
HAZARD PREVENTION —
"type": "module"with a.jsCJS artifact Setting"type": "module"inpackage.jsontells Node.js to parse every.jsfile as ESM. If your build emits a CJS artifact asdist/cjs/index.js, Node.js throwsSyntaxError: Cannot use import statement in a module(or the inverse,require is not defined). Fix: name CJS artifacts.cjsand ESM artifacts.js(or.mjs) when"type": "module"is set.
HAZARD PREVENTION —
require()of ES Module error afterNode16migration After upgrading to"moduleResolution": "Node16", consumers may hitERR_REQUIRE_ESMwhen callingrequire()on your package. This means the"require"condition in yourexportsmap points to an.jsfile treated as ESM. Fix: confirm your CJS artifact uses the.cjsextension or that"type"is absent from the CJS sub-package. See Fixing require() Errors in Pure ESM Packages for the full repair sequence.
Tooling Validation
Run these commands before every publish to catch hazard-inducing mistakes automatically.
# 1. publint — validates exports map structure, condition ordering, and file existence
npx publint
# 2. are-the-types-wrong — checks TypeScript entry points match the runtime entries
npx attw --pack .
# 3. Type-check without emitting — catches moduleResolution mismatches
npx tsc --noEmit
# 4. Node.js built-in: print resolved paths for both conditions
node -e "
const { createRequire } = require('module');
const r = createRequire(require.resolve('.'));
console.log('CJS:', r.resolve('.'));
"
node --input-type=module -e "
import { resolve } from 'import-meta-resolve';
console.log('ESM:', await resolve('.', import.meta.url));
"
Sample publint pass output:
✔ No issues found
Sample publint fail output (before fixing condition ordering):
⚠ "exports['.']" should list "import" before "require" to prevent CJS consumers from receiving the ESM build under Node.js 12–16
Sample attw pass output:
┌─────────┬──────────────────────┬─────────────────┐
│ (index) │ entrypoint │ types │
├─────────┼──────────────────────┼─────────────────┤
│ ESM │ dist/esm/index.js │ ✔ OK │
│ CJS │ dist/cjs/index.cjs │ ✔ OK │
└─────────┴──────────────────────┴─────────────────┘
CI Validation Matrix
Single-format test runs miss cross-boundary failures. Run the same suite against both entry points on every push:
name: Dual-Format Validation
on: [push, pull_request]
jobs:
test-matrix:
strategy:
matrix:
node-version: [18, 20, 22]
format: [esm, cjs]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run build
- name: Lint exports map
run: npx publint
- name: Check types
run: npx attw --pack .
- name: Run format-specific tests
run: |
if [ "${{ matrix.format }}" == "esm" ]; then
node --test --experimental-test-coverage test/esm/*.test.mts
else
node --test test/cjs/*.test.cts
fi
For test isolation in Vitest when both module formats load in the same process, disable module cache sharing between test files:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: false,
isolate: true,
sequence: { concurrent: false },
},
});
Compatibility Matrix
| Node.js | exports conditions |
.cjs / .mjs extensions |
--experimental-require-module |
|---|---|---|---|
| 12.17 | Stable | Required | Not available |
| 14 LTS | Stable | Required | Not available |
| 16 LTS | Stable | Required | Not available |
| 18 LTS | Stable | Required | Not available |
| 20 LTS | Stable | Required | Flag only |
| 22 Current | Stable | Required | Enabled by default (breaking edge case) |
| Bundler | Respects exports conditions |
Default conditions used |
|---|---|---|
| webpack 5 | Yes | browser, module, import, default |
| Rollup 4 | Yes | import, module, default |
| esbuild | Yes (with --conditions) |
import, default |
| Vite 5 | Yes | import, module, browser, default |
| tsup 8 | Yes | Mirrors esbuild defaults |
| TypeScript | moduleResolution for exports |
Notes |
|---|---|---|
| 4.7–4.9 | node16 / nodenext |
First release with condition awareness |
| 5.0–5.4 | node16 / nodenext / bundler |
bundler mode for Vite/esbuild consumers |
| 5.5+ | node16 / nodenext / bundler |
Improved exports diagnostic messages |
Detecting a Split Instance in a Running Application
The hazard is easy to describe and hard to notice, because a duplicated package usually works — until state stops being shared. A registry populated in one instance is empty in the other; an event emitter fires listeners that the other half never registered; an instanceof check against a class exported by the package returns false for an object the package itself created. The symptom reaches the maintainer as “your library randomly loses my configuration”, which is nearly impossible to act on without a way to prove duplication.
The most direct probe is a module-scoped identity token. Adding one to your package — permanently, not just while debugging — turns an invisible failure into a one-line diagnosis:
// src/instance-id.ts
export const INSTANCE_ID: symbol = Symbol("my-library instance");
const GLOBAL_KEY = "__my_library_instances__";
const registry = ((globalThis as Record<string, unknown>)[GLOBAL_KEY] ??= new Set<symbol>());
(registry as Set<symbol>).add(INSTANCE_ID);
if ((registry as Set<symbol>).size > 1 && process.env.NODE_ENV !== "production") {
console.warn(
`[my-library] ${(registry as Set<symbol>).size} copies of this package are loaded in one process. ` +
"State will not be shared between them — see the dual-package hazard guide."
);
}
Consumers then see the warning the moment the split happens, at load time, instead of debugging missing state hours later.
From the consumer’s side, the equivalent probe needs no cooperation from the package:
node --experimental-loader=/dev/null -e "
const path = require.resolve('my-lib');
console.log('cjs resolves to:', path);
" 2>/dev/null
node --input-type=module -e "
const url = import.meta.resolve('my-lib');
console.log('esm resolves to:', url);
"
If those two commands print paths to different files, the package can be duplicated in that application. That is not automatically a bug — a stateless utility library is perfectly safe with two copies — but it is the precondition, and it tells you which packages to look at when state goes missing.
Choosing a Strategy: Thin Wrapper, ESM-Only, or Externalised State
There are exactly three durable answers to the hazard, and the right one depends on what your package holds rather than on taste.
The thin CommonJS wrapper. Ship the real implementation as ESM only, and make the require condition a tiny CommonJS file that does nothing but re-expose it. On Node 22+ this is a require() of the ESM build; on older versions it is a shim that throws a clear message rather than loading a second copy. Both formats then reach the same module instance, and there is exactly one copy of your state. The cost is that the CommonJS entry inherits ESM’s constraints — no synchronous access on older runtimes.
ESM-only. Publishing a single ESM build removes the hazard by removing the second format. This is increasingly viable — Node 22 can require() ESM, and bundler support is universal — but it is a breaking change for any consumer stuck on an older runtime or a build pipeline that cannot process ESM dependencies. It is the right call for a new package and a disruptive one for an established package with a large CommonJS install base.
Externalised state. Keep both builds, but move everything stateful onto a well-known global key so both copies share it. This is what several widely-used libraries do in practice, and it is the only option that tolerates duplication rather than preventing it:
const STATE_KEY = Symbol.for("my-library/state"); // Symbol.for is cross-realm and cross-copy
interface LibraryState { plugins: Map<string, Plugin>; }
function getState(): LibraryState {
const g = globalThis as Record<symbol, unknown>;
return (g[STATE_KEY] ??= { plugins: new Map() }) as LibraryState;
}
Symbol.for is doing the work: unlike Symbol(), it looks up a process-wide symbol registry, so both copies of the module resolve the same key. Note what this does not fix — instanceof still fails across copies, because the class objects remain distinct. If your public API asks consumers to do type checks against exported classes, externalised state is not enough on its own, and a structural check (isPlugin(value) testing for a branded property) is the accompanying change.
Whichever strategy you pick, encode it as a test rather than a convention. A test that loads the package through both conditions in one process and asserts the identity token matches will fail the day someone adds a second entry point that reintroduces the split — which is precisely when a maintainer is least likely to be thinking about the hazard.
The Three Strategies Side by Side
Choosing between the strategies is easier as a comparison than as prose, because each trades a different thing away.
Packages that expose classes in their public API should read the right-hand column carefully: shared state alone does not restore identity, and an API that asks consumers to type-check with instanceof needs one of the other two strategies.
Pages in This Section
-
Fixing require() Errors in Pure ESM Packages — step-by-step repair for the
ERR_REQUIRE_ESMerror consumers hit when callingrequire()on a pure-ESM package. -
Keeping Singletons Safe Across ESM and CJS — sharing state across duplicated copies with a registered symbol, and the identity checks it does not fix.
Related
- Mastering the package.json
exportsField — deep dive into condition ordering, subpath exports, and thedefaultfallback that prevents accidental CJS routing. - Understanding ESM vs CJS Module Formats — explains the static binding model and separate module caches that make dual-loading possible.
- Browser vs Node.js Module Resolution — covers how bundlers negotiate conditions differently from Node.js, which affects which artifact gets loaded.
- Path Mapping and Module Resolution Strategies — covers
paths,baseUrl, andmoduleResolutionsettings that interact with condition negotiation in TypeScript. - Modern Build Tools: tsup, Rollup, and esbuild — shows how to configure each build tool to emit correctly named
.cjsand.mjsartifacts that feed a cleanexportsmap.
Back to Module System Fundamentals & Dual-Package Resolution