Node.js's own documentation names a specific failure mode: the "dual package hazard," where a package that ships both a CommonJS build and an ES module build gets loaded twice by the same process — once through require() and once through import — and Node treats the two as entirely separate module instances. That means two copies of the same class, two separate module-level caches, and two Symbol registries that don't recognize each other. It's not a hypothetical: any library that relies on instanceof for type checks, singleton state, or plugin registration can silently misbehave the moment one transitive dependency pulls in the CJS build while another pulls in the ESM build of the identical package. Node stabilized "conditional exports" — the "exports" field in package.json with "require" and "import" conditions — as the sanctioned fix starting in the Node 12/13 era, and getting that map wrong is now one of the most common sources of ERR_REQUIRE_ESM and ERR_PACKAGE_PATH_NOT_EXPORTED errors reported against popular packages. This piece walks through why the hazard happens, how to structure a dual package that avoids it, and how to keep the two builds from drifting into two different dependency trees — a distinct but related supply-chain risk that has nothing to do with module formats and everything to do with what npm install actually resolves.
What exactly is the dual package hazard?
The dual package hazard is Node's term, used in its own packages.md documentation, for what happens when the same underlying package gets loaded into a process through two different module systems and Node has no way to know they're "the same" package. If require('pkg') resolves to pkg/dist/index.cjs and import('pkg') resolves to pkg/dist/index.mjs, Node evaluates each file independently, giving each its own module-level state. Any code that exports a class and expects instanceof to hold across the two entry points will fail unpredictably, because the CJS Error subclass and the ESM Error subclass are now different constructors. That's a genuine security-relevant bug class when authorization or validation logic branches on instanceof — a check that silently returns false instead of throwing doesn't look like a crash, it looks like a bypass. The same problem hits module-level caches (a rate limiter or in-memory store keyed on a shared singleton effectively becomes two limiters) and any registry pattern that stores objects by identity rather than by value.
Why do conditional exports fix it, and where do they still go wrong?
Conditional exports fix the hazard by letting a single package.json tell Node's resolver exactly which file to hand back for a given load mechanism, instead of relying on file extensions or a guessed main field. The "exports" map supports "require", "import", "node-addons", "node", and "default" conditions, and Node evaluates them in that priority order depending on how the module was loaded. Done correctly, both conditions can point at builds that share the exact same underlying logic, so there's only ever one "real" implementation. Where teams go wrong: shipping an "exports" map that only covers the package root while consumers deep-import a subpath Node then can't resolve (ERR_PACKAGE_PATH_NOT_EXPORTED), or defining "exports" without a matching "type": "module"/"type": "commonjs" pairing per file extension, which produces ERR_REQUIRE_ESM when a .js file is loaded under the wrong assumed format. Both errors are resolution bugs, not runtime bugs — they surface at npm install-then-require() time, which is exactly why they should be caught in CI against the published tarball, not just the source tree.
What's the safest architectural pattern for avoiding divergent builds?
The pattern cited most often as safe practice is to maintain one source of truth — almost always the CommonJS build — and make the ESM entry point a thin wrapper that re-exports it, rather than maintaining two independently compiled and independently tested code paths. When a project instead maintains genuinely separate ESM and CJS source trees, or lets a bundler produce two builds with different transpilation targets, the two builds can end up depending on different versions of the same transitive package, or including a helper polyfill in one build that's absent from the other. That divergence is where the dual package hazard stops being an edge case and starts being a live production bug, because now the "two instances" problem isn't just about module identity — the two instances may contain genuinely different code. Keeping one canonical implementation and one thin re-export shim is less a stylistic preference than a way to guarantee that whichever entry point a consumer's bundler resolves, they get identical behavior and an identical dependency graph underneath it.
Is this the same problem as dependency confusion?
No, and conflating them is a common mistake. The dual package hazard is a resolution problem inside a single package you control — the same code loaded twice. Dependency confusion is an entirely different attack: a public npm package published under the same name as a company's private, internally-scoped package, so that a misconfigured install pulls the attacker's public version instead of the internal one. Security researcher Alex Birsan demonstrated this at scale in 2021, publishing public packages that matched internal library names at 35 companies — including Apple, Microsoft, PayPal, Shopify, Netflix, Tesla, and Uber — and collecting over $130,000 in bug bounties as a result. A dual ESM/CJS package can still be a dependency-confusion target — nothing about shipping both module formats makes a package immune — which is why treating "safe module resolution" and "safe registry resolution" as two separate checklist items matters: fixing your exports map does nothing to stop someone from squatting your unscoped package name on the public registry.
Does a malicious transitive dependency ever actually reach production this way?
Yes, though the clearest historical example — event-stream — is a supply-chain incident, not a dual-package incident, and it's worth being precise about that distinction. In September 2018, a new contributor was given publish access to the popular event-stream package and added flatmap-stream@0.1.1 (published October 5, 2018) as a dependency of event-stream@3.3.6; the added package contained code specifically targeting the Copay Bitcoin wallet's build process to exfiltrate credentials. The compromise was discovered and reported via a GitHub issue, and the malicious package was pulled from npm on November 26, 2018. Nothing about that attack involved ESM/CJS resolution — it's cited here because it's the canonical example of what a malicious transitive dependency looks like in practice, and dual-package builds widen the transitive surface slightly by definition: a CJS build and an ESM build each pull their own dependency subtree unless you deliberately collapse them into one, which is one more reason the single-source-of-truth pattern above is worth the discipline.
How should a package maintainer verify a dual package before publishing it?
Verification should happen against what actually gets published, not against the source tree, because files, .npmignore, and exports bugs only manifest in the packed tarball. Running npm pack and then testing both require() and import against the resulting .tgz — in a clean, empty project with no source-level path shortcuts — catches the ERR_PACKAGE_PATH_NOT_EXPORTED and ERR_REQUIRE_ESM class of bugs before a consumer hits them. Tools like @arethetypeswrong/cli exist specifically to catch exports-map and TypeScript type-resolution mismatches between the two conditions. Because deep dependency chains are where both classes of risk — a stale package a dual build accidentally forked into two trees, and a compromised transitive dependency like flatmap-stream — tend to hide, Safeguard's dependency scanning walks resolved graphs up to 100 levels deep and tags cross-registry mismatches with a dependency-confusion finding, giving maintainers a way to confirm both builds resolve to the same audited dependency tree before a release ships.