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

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.

One process, two instances Inside a single Node.js process, an ESM entry point imports the package while a CommonJS dependency requires it. Each path resolves through a different cache, producing two module instances whose registries, singletons and class identities are separate. Two loaders, two copies, one confused application app entry (ESM) import "my-lib" a CommonJS dependency require("my-lib") ESM loader import condition CommonJS loader require condition instance A its own registry + classes instance B separate registry + classes instanceof fails across the divide a value created by A is not an A to B

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.

Three hazard strategies compared The thin CommonJS wrapper prevents duplication and keeps both entry points, at the cost of ESM constraints on the CommonJS path. ESM-only removes the hazard entirely but drops CommonJS consumers. Externalised state tolerates duplication and shares data, but class identity checks still fail. What each strategy costs thin wrapper one instance always both entry points kept instanceof keeps working cost: CJS entry inherits ESM loading constraints ESM-only hazard cannot occur simplest build by far smallest published surface cost: a major release and lost CommonJS consumers externalised state duplication is tolerated state stays shared no consumer-visible change cost: instanceof still fails across the two copies

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



Back to Module System Fundamentals & Dual-Package Resolution