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 |
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.
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