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

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:

  • import must precede require to prioritize ESM consumers.
  • default acts as a strict fallback; placing it earlier causes ambiguous resolution.
  • Explicit types sub-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.