Dual Package Hazard in ESM and CommonJS Explained
The dual package hazard is a critical architectural issue in the
Node.js ecosystem that occurs when a package provides both ECMAScript
Modules (ESM) and CommonJS (CJS) versions of its codebase. When an
application or its dependencies simultaneously load the ESM version via
import and the CJS version via require(),
Node.js treats them as two completely separate modules. This article
explains how the dual package hazard arises, the runtime bugs it
creates, and how package authors can avoid it.
How the Hazard Arises
Node.js manages ESM and CommonJS modules through two independent
resolution and caching systems. When a module is loaded using
import, it is resolved and stored in the ESM module graph.
When a module is loaded using require(), it is resolved and
stored in the CommonJS require.cache.
The hazard typically emerges when a package author configures
conditional exports in package.json to support both
formats:
{
"name": "my-library",
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}If an application imports my-library directly using ESM
syntax, Node loads ./dist/index.mjs. If a third-party
dependency used by that application requires my-library
using CommonJS syntax, Node loads ./dist/index.cjs. Because
Node.js does not recognize that these two files represent the same
logical package, both files are executed and kept in memory
simultaneously.
Consequences of the Dual Package Hazard
When two separate instances of a package run concurrently within the same process, several breaking behaviors can occur:
1. Broken Singletons and State Duplication
If a library maintains internal state—such as caches, connection pools, or registry tables—loading both versions creates two distinct instances of that state. Updates made by the ESM code will not be visible to the CJS code, leading to desynchronization and difficult-to-trace bugs.
2. instanceof Failure
When a library exports a class, the ESM and CommonJS builds create
two separate constructor functions with different prototype references.
An object instantiated from the CJS version will fail an
instanceof check when evaluated by code using the ESM
version:
import { CustomError } from 'my-library'; // ESM instance
const { produceError } = require('my-dependency'); // Uses CJS my-library
const error = produceError();
console.log(error instanceof CustomError); // Evaluates to false3. Increased Memory Footprint
Duplicate module execution inflates the application’s memory usage and increases initialization time, as initialization scripts, schemas, and static data are parsed and stored twice.
How to Prevent the Hazard
Package authors can prevent the dual package hazard using several strategies:
- The CommonJS Wrapper Pattern: Write the primary implementation in CommonJS and provide a thin ESM wrapper that simply re-exports the CommonJS module. Because ESM can import CommonJS, both consumers ultimately share the single CommonJS instance in memory.
- Isolate Shared State: If separate builds are required, move any shared state, singletons, or base classes into a dedicated CommonJS-only package that both the ESM and CJS variants depend on.
- Pure ESM Transition: Publish the package exclusively as ESM, eliminating the CommonJS build entirely and requiring downstream consumers to use modern module standards or dynamic imports.