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:

Dual-package hazard: ESM and CJS module caches diverge A data-flow diagram showing a Node.js process at the top splitting into two loaders — the ESM loader (left) and the CJS loader (right) — each loading a separate copy of your-library, resulting in two isolated module instances with no shared state between them. Node.js process ESM loader import 'your-library' CJS loader require('your-library') dist/esm/index.js Instance A dist/cjs/index.cjs Instance B no shared state instanceof fails · singleton diverges · cache misses silent corruption — no error thrown

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 PREVENTIONglobalThis bridging is a last resort. It introduces global mutable state that makes testing harder and is invisible to bundler tree-shaking. Fix the exports map first; reach for globalThis only 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 — instanceof returning false across the boundary When two module instances load the same class definition, each has a distinct prototype chain. foo instanceof MyClass returns false even when foo was constructed with what appears to be the same class. Root cause: the MyClass reference in the ESM instance and the MyClass reference in the CJS instance are different objects. Fix: ensure only one instance loads (correct exports ordering) or use duck-typing ('method' in foo) instead of instanceof in 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 through globalThis.

HAZARD PREVENTION — "type": "module" with a .js CJS artifact Setting "type": "module" in package.json tells Node.js to parse every .js file as ESM. If your build emits a CJS artifact as dist/cjs/index.js, Node.js throws SyntaxError: Cannot use import statement in a module (or the inverse, require is not defined). Fix: name CJS artifacts .cjs and ESM artifacts .js (or .mjs) when "type": "module" is set.

HAZARD PREVENTION — require() of ES Module error after Node16 migration After upgrading to "moduleResolution": "Node16", consumers may hit ERR_REQUIRE_ESM when calling require() on your package. This means the "require" condition in your exports map points to an .js file treated as ESM. Fix: confirm your CJS artifact uses the .cjs extension 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



Back to Module System Fundamentals & Dual-Package Resolution