One caller increments a package's counter to one. Another still reads zero. There is only one installed copy in this example, at version 0.0.0.

The difference is the entry file. Node selects one implementation for require() and another for import(), and each creates its own object. A small fixture makes that visible without installing a third-party package.

Two entry files, two stores

The synthetic package uses this package.json:

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

index.cjs creates a store:

const store = { count: 0 }; module.exports = { store, increment() { store.count++; } }; 

index.mjs creates another one:

export const store = { count: 0 }; export function increment() { store.count++; } 

With that package in the fixture's node_modules, the CommonJS probe loads both entries:

(async () => { 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); })().catch(error => { console.error(error); process.exitCode = 1; }); 

Both recorded rounds on Node v22.22.3 produced:

false 1 0 

Changing one store did not change the other. There was no second package version to deduplicate in this fixture.

A control: require the same entry twice

The first comparison in the script calls require('lab-dual') twice in one process. Both callers receive the same store. After an increment through the first reference, both read one.

This comparison matters when diagnosing the failure. Seeing two initialization paths does not establish that the CommonJS cache failed; the package can explicitly expose two implementations.

A wrapper around one core

The second synthetic package sends require directly to core.cjs. That file owns the store. Its ESM entry imports the same core:

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

With one increment from each caller, the identity check is true and both read two.

Comparison Same store? Count through require Count through the other caller
Repeated require true 1 1
Separate CJS and ESM implementations false 1 0
ESM wrapper over the shared CJS core true 2 2

These outcomes matched in two rounds. Each round used a fresh directory, and each comparison ran in its own Node process.

The wrapper tests mutation of count inside one shared object. Reassigning core.store to an entirely new object would need a separate test. The result also does not establish state sharing across processes or through a bundler.

Reproduce it

The complete self-contained script creates the two synthetic packages, runs the comparisons, checks assertions and removes its temporary directory. It requires Node 22 or later and has no install step.

Download run.mjs, then run:

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

Choose a new record path. The script refuses to overwrite an existing one. The published record includes fixture sources, script digest, environment and both rounds.

This experiment covers synthetic packages on one macOS/arm64 machine with Node v22.22.3. It does not test a real npm library or a production application's singleton design.

For a real reproduction, include the package version, the caller's module format, the selected entry files and the code that creates the state. Compare object identity as well as values: equal counts alone do not demonstrate a shared object.

The original experiment is published on NPMBot. Node's package entry point documentation describes the condition selection behind the fixture.

AI assistance was used to prepare the text and fixture. The observations above come from actual local script runs; the executable source and records are linked so readers can inspect the scope and reproduce them.