Node.js · Recorded experiment

Node.js Dual Package Hazard: Two Copies of State

A single installed package can expose separate CommonJS and ESM state. Compare object identity across require, import and a shared CommonJS core in six Node processes.

Recorded: (UTC) · Node v22.22.3 · darwin 27.0.1 / arm64

Read this article in: 简体中文

One caller increments a package's counter to one. Another caller still reads zero. In this fixture there is one installed copy and one version, 0.0.0. The callers reach different entry files, each of which creates its own state.

The experiment compares repeated require calls, mixed require/import calls and an ESM wrapper around a shared CommonJS core. Each comparison runs in a separate Node process. Two rounds produce the same object-identity and counter results.

Two recorded runs

The same script in two independent runs
ScenarioRun 1Run 2
Require the same entry twiceSame object: true; require side: 1; other side: 1Same object: true; require side: 1; other side: 1
Require and import separate entriesSame object: false; require side: 1; other side: 0Same object: false; require side: 1; other side: 0
Both entries use core.cjsSame object: true; require side: 2; other side: 2Same object: true; require side: 2; other side: 2

One package directory can contain two state owners

The lab-dual exports map sends require to index.cjs and import to index.mjs. Each file creates a store object and an increment function. Changing the count on one object does not change the other.

In the mixed-entry case, sameStore is false. After incrementing through require, that side reads one and the imported side reads zero. There is no second package version to deduplicate in this fixture. To explain the state, inspect the selected entry and where it creates its object.

{
  "name": "lab-dual",
  "version": "0.0.0",
  "exports": {
    "import": "./index.mjs",
    "require": "./index.cjs"
  }
}

Use repeated require as a control

The first comparison requires lab-dual twice in one process. Incrementing through the first result changes the count seen through both references. They point to the same store object and both read one.

Comparing that result with the mixed-entry case narrows the explanation to distinct implementations. It prevents an overly broad diagnosis such as treating every duplicate initialization as a failure of the CommonJS module cache. The script records object identity alongside the values so equal numbers alone cannot be mistaken for shared state.

const a = require('lab-dual');
const b = await import('lab-dual');
a.increment();
console.log(a.store === b.store, a.store.count, b.store.count);
// Observed for the mixed-entry case: false 1 0

Make the wrapper use the same core

The second package, lab-shared, sends require directly to core.cjs. Its ESM wrapper imports that core and exports references to the core's store and increment function. Each caller increments once; both then read two, and the identity comparison is true.

This tests mutation of count inside a shared object. It does not test reassigning core.store to a different object later. The wrapper captures a reference, so that change would need its own example. Sharing this object also says nothing about state across separate processes.

// wrapper.mjs
import core from './core.cjs';
export const store = core.store;
export const increment = core.increment;

Include entry selection in a reproduction report

Alongside the exact package version, record whether each caller uses require or import and which files the exports map selects. Follow those files to the code that initializes state. A package name alone cannot describe those choices.

NPMBot's compatibility checker can help inspect declared entry selection for a public release. Establishing object identity needs a runtime example such as this one. These six child processes load only the two synthetic packages; no third-party package, bundler output or production application was tested.

Run the experiment on your machine

Use Node.js 22 or later. No dependencies need to be installed. The script uses Node built-in modules, runs two rounds and checks assertions. Choose a new record path so you do not overwrite an earlier result.

node --version
node run.mjs
node run.mjs --record my-results.json

Each round creates a new fixture directory, and each comparison uses a new Node process. Identity is compared inside that process. The fixtures do not establish cross-process state sharing, bundler deduplication or the singleton behavior of a real npm library. Complete fixture sources and their digest are included in the record.

Both rounds were run by the same operator on one machine. No independent reader rerun has been recorded. Save your own output when trying another environment; a failed assertion stops the script.

Check the script SHA-256

0e3096e8f2163b919c68e1974f5149d9842edeadaa8cbb0f3b9d5ba0fb7326a1

This digest identifies the downloadable script. Full observations are in verification.json.