
Why Is Your Node.js App Still Loading the Same Module Twice?
Uncover the 'Dual Package Hazard' that causes Node.js to instantiate separate versions of the same library, leading to bloated bundles and broken singleton states.
I spent three hours yesterday wondering why my singleton logger was acting like it had amnesia. I’d set the log level to debug in my entry point, but half my modules were still spitting out info logs as if I’d never touched the config. It turns out I wasn't crazy; I was just haunted by the ghost of modules past.
If you’ve ever seen your Node.js app behave like it’s running two completely different versions of the same library simultaneously, you’ve likely stumbled into the Dual Package Hazard.
The split personality of modern Node
Node.js is currently living through a long, awkward transition. We have the venerable CommonJS (require) and the shiny, standardized ECMAScript Modules (import). To make everyone happy, many library authors ship "dual packages"—one version for CJS users and one for ESM users.
The trouble starts when your dependency graph looks like a bowl of spaghetti. If a package is imported via require in one file and import in another, Node.js treats them as two entirely different entities.
Node's module loader caches modules based on their absolute filename. In a dual package, index.cjs and index.mjs are different files. Therefore, they get different entries in the cache, different execution contexts, and separate internal states.
Seeing the double vision in action
Let’s say you’re building a simple counter library called tiny-state. You want to be a good citizen, so you provide both formats.
package.json
{
"name": "tiny-state",
"type": "module",
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}index.mjs (ESM version)
export const store = { count: 0 };
console.log('ESM module loaded');index.cjs (CJS version)
const store = { count: 0 };
console.log('CJS module loaded');
module.exports = { store };Now, imagine an app where a legacy middleware uses require and a new service uses import.
// main.js
import { store as esmStore } from 'tiny-state';
const { store: cjsStore } = require('tiny-state');
esmStore.count = 10;
console.log(`ESM Count: ${esmStore.count}`); // 10
console.log(`CJS Count: ${cjsStore.count}`); // 0When you run this, you’ll see both "ESM module loaded" and "CJS module loaded" in your console. You now have two store objects. If this were a database connection pool or a configuration singleton, your app is now effectively broken.
Why "instanceof" suddenly fails you
The Dual Package Hazard doesn't just break state; it breaks identity. This is a nightmare for libraries that rely on instanceof checks.
If you create an error class in the CJS version of a library and try to catch it in an ESM file, error instanceof MyCustomError will return false. Why? Because the ESM file is checking against the MyCustomError class defined in the .mjs file, but the error was instantiated from the class in the .cjs file. They share a name, but they don't share a prototype chain.
How to stop the bleeding
If you're an author of a library, you have a few ways to dodge this bullet.
1. The CJS Wrapper (The "Safe" Way)
The most robust way to avoid the hazard is to make one format a thin wrapper around the other. Usually, you write the core logic in CJS and have the ESM version just re-export it. Since ESM can import CJS, but CJS can't synchronously require ESM, CJS is the "lowest common denominator."
dist/index.cjs
// All your actual logic goes here
class Singleton { ... }
module.exports = { Singleton };dist/index.mjs
import cjsModule from './index.cjs';
export const Singleton = cjsModule.Singleton;By doing this, even if the user imports both files, the index.mjs file is just pointing back to the exports of index.cjs. Node's cache handles the rest, and you only get one instance of your logic.
2. Isolate your state
If you absolutely must have separate codebases for CJS and ESM (maybe for tree-shaking reasons), keep your state in a third, shared file that both versions reference.
However, this is tricky because you still run into the file path caching issue. You’d essentially need a "state-only" file that both the .mjs and .cjs files require (which works, because ESM can import CJS).
As a consumer, what can you do?
If you're just using a library that suffers from this, your options are limited:
1. Pick a side. Try to stick strictly to import or require throughout your entire project.
2. Point to a specific entry point. Instead of import { x } from 'pkg', you might have to deep-import a specific file like import { x } from 'pkg/dist/index.cjs'. It's ugly, but it forces a single cache entry.
3. Check your dependencies' dependencies. Sometimes *you* are using ESM, but two of your dependencies use the same library—one via ESM and one via CJS. In this case, use a tool like npm list or yarn why to see if multiple versions or formats are being pulled in.
The "Pure ESM" nuclear option
Lately, the community has seen a push toward "Pure ESM" packages. High-profile maintainers (like those behind sindresorhus packages) have dropped CJS support entirely.
While controversial, it solves the Dual Package Hazard by simply deleting one side of the equation. If there is no CJS version, there is no hazard. It forces the consumer to upgrade, which is painful in the short term but cleaner for the ecosystem in the long run.
The Dual Package Hazard is one of those "invisible" bugs. It doesn't throw a stack trace; it just quietly duplicates your memory and breaks your logic. If you're seeing weird behavior in a hybrid codebase, start checking your node_modules for those sneaky double-loads.

