Navigating the Dual-Package Hazard
Prevent singleton state corruption when ESM and CJS load the same package twice. Fix exports field condition ordering, centralize shared state, and automate validation in CI.
The dual-package hazard occurs when a single dependency is instantiated twice within the same Node.js process due to divergent module resolution paths. As modern ecosystems transition toward ECMAScript Modules (ESM) while maintaining CommonJS (CJS) compatibility, library maintainers and platform engineers must architect around this cache divergence to prevent silent state corruption. For a comprehensive breakdown of how this hazard fits into broader ecosystem architecture, refer to Module System Fundamentals & Dual-Package Resolution.
Defining the Dual-Package Hazard
Node.js maintains two entirely separate module caches: require.cache for synchronous CJS evaluation and an internal, opaque registry for asynchronous ESM imports. When a hybrid package exposes both formats without strict resolution boundaries, the runtime may load the same logical module twice. This creates isolated singleton instances that never share memory, breaking framework initialization, configuration objects, and plugin registries.
The divergence stems from historical resolution behavior where .mjs files, .cjs files, and package.json "type": "module" directives trigger different evaluation pipelines. Understanding how differing evaluation models and synchronous vs asynchronous loading trigger cache isolation is critical; see Understanding ESM vs CJS Module Formats for the underlying execution semantics.
Runtime Environment Detection & Cache Inspection
To verify cache splits in production or staging, inspect both registries simultaneously. The following diagnostic script isolates dual-loaded modules by comparing resolved paths across formats:
#!/usr/bin/env node
// hazard-inspection.mjs
// HAZARD PREVENTION NOTE: Run this in a process that consumes both ESM and CJS entry points.
// Identical paths appearing in both outputs confirm a dual-package hazard.
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
const cjsRequire = createRequire(import.meta.url);
const targetPackage = 'your-dependency';
// Inspect CJS cache
const cjsResolved = cjsRequire.resolve(targetPackage);
const cjsCached = Object.keys(require.cache).filter(p => p.includes(targetPackage));
// Inspect ESM cache (Node.js 20+ exposes internal registry via diagnostics)
console.log('CJS Resolved Path:', cjsResolved);
console.log('CJS Cache Entries:', cjsCached);
console.log('️ If both formats resolve to identical logical paths but different cache entries, state will diverge.');
Resolution Mechanics & Conditional Exports Configuration
Modern Node.js versions resolve dual formats exclusively through the exports field in package.json. Ambiguous resolution occurs when condition priority is misconfigured, causing bundlers or runtimes to fall back to unintended entry points. The strict evaluation order for conditional exports is: import → require → node → default.
Explicit file mapping must always override directory fallbacks. Relying on implicit main/module fields guarantees cache splits in hybrid environments. For precise conditional export mapping and fallback ordering, consult Mastering the package.json Exports Field.
Production-Ready package.json Configuration
{
"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"
}
}
}
Hazard Prevention Notes:
importmust precederequireto prioritize ESM consumers.defaultacts as a strict fallback; placing it earlier causes ambiguous resolution.- Explicit
typessub-conditions prevent TypeScript from resolving mismatched declaration files.
TypeScript Alignment
Mismatched moduleResolution settings bypass Node’s conditional export logic during compilation:
{
"compilerOptions": {
"moduleResolution": "node16",
"module": "Node16",
"esModuleInterop": true,
"typesVersions": {}
}
}
Pitfall Fix: Setting moduleResolution to node16 or nodenext forces the compiler to respect runtime exports conditions. Legacy node or classic resolution strategies will ignore conditional exports and trigger cache splits.
CI/CD Validation & Cross-Format Testing Workflows
Automated pipelines must validate both consumption formats in parallel. Single-format validation masks dual-package regressions until consumer-side integration failures occur. A robust matrix strategy executes the test suite against explicit ESM and CJS consumer harnesses.
GitHub Actions Matrix Configuration
# .github/workflows/dual-format-validation.yml
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
- name: Setup Node ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install Dependencies
run: npm ci
- name: Run Format-Specific Tests
run: |
if [ "${{ matrix.format }}" == "esm" ]; then
node --test --experimental-test-coverage test/esm/*.test.mjs
else
node --test test/cjs/*.test.cjs
fi
Dual-Runner Environment Configuration
When using Jest or Vitest, isolate ESM execution to prevent cache pollution:
// vitest.config.mjs
// HAZARD PREVENTION NOTE: Explicitly disable module caching between test files.
// Use `--experimental-vm-modules` in Jest to avoid ESM/CJS interop crashes.
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: false,
isolate: true, // Prevents shared state leakage across test contexts
sequence: { concurrent: false }, // Ensures deterministic cache behavior
},
});
Pitfall Fix: CI pipelines that only validate a single module format miss cross-boundary failures. The matrix above forces dual execution. For common consumer-side resolution failures during validation, see Fixing require() Errors in Pure ESM Packages.
State Synchronization & Runtime Mitigation Strategies
When dual-package loading is unavoidable due to legacy consumer constraints, architectural patterns must enforce state consistency across module boundaries. Relying on module-level singletons in hybrid environments guarantees divergence. Instead, route shared state through centralized managers or explicit initialization functions that bypass module caching.
Centralized State Manager Pattern
// state-registry.ts
// HAZARD PREVENTION NOTE: Avoid top-level mutable exports. Use a factory that returns
// a shared instance or explicitly synchronizes state via a global registry.
const globalRegistry = globalThis as typeof globalThis & { __LIB_STATE__?: Map<string, unknown> };
export function getSharedState<T>(key: string, factory: () => T): T {
if (!globalRegistry.__LIB_STATE__) {
globalRegistry.__LIB_STATE__ = new Map();
}
if (!globalRegistry.__LIB_STATE__.has(key)) {
globalRegistry.__LIB_STATE__.set(key, factory());
}
return globalRegistry.__LIB_STATE__.get(key) as T;
}
Bundler Alias Overrides
Frontend toolchains often misinterpret conditional exports. Force single-format resolution at the bundler layer to prevent runtime duplication:
Vite Configuration:
// vite.config.js
export default {
resolve: {
alias: {
// HAZARD PREVENTION NOTE: Force ESM resolution for Node.js dual packages
'your-dependency': 'your-dependency/dist/esm/index.js',
},
conditions: ['import', 'module'], // Overrides default condition priority
},
};
esbuild Configuration:
# HAZARD PREVENTION NOTE: Use --conditions to align bundler resolution with Node.js runtime expectations.
esbuild src/index.ts --bundle --platform=node --conditions=import,module --outfile=dist/bundle.js
Dynamic Import Fallbacks & Compatibility Shims
For environments where static resolution fails, wrap dual-format consumers in dynamic import bridges:
// cjs-compat-shim.cjs
// HAZARD PREVENTION NOTE: Dynamically import ESM modules to bypass synchronous require() cache isolation.
// This ensures the runtime loads the exact same instance across async boundaries.
module.exports = async function loadDependency() {
const esmModule = await import('your-dependency');
return esmModule.default || esmModule;
};
Pitfall Summary & Resolution:
| Issue | Resolution |
|---|---|
| Singleton state splits across ESM/CJS boundaries | Route all shared state through a dedicated initialization function or centralized store that bypasses module caching. |
Incorrect exports condition ordering causing CJS fallback |
Always place import before require in the exports map, and explicitly define default as the final fallback. |
| CI pipelines only validating a single module format | Configure a matrix strategy that runs the test suite twice: once with type: module and once with explicit require() consumers. |
TypeScript moduleResolution mismatching Node.js behavior |
Set moduleResolution to node16 or nodenext and ensure typesVersions aligns with runtime exports conditions. |