Why a Package Breaks in a CommonJS App: Field Notes on require(esm), the exports Map, and the Dual Package Hazard
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Node.js 22.12 and later can require() a synchronous ES module, which removes the original reason most libraries shipped two builds, but it does not remove the dual package hazard and it does not forgive a badly ordered exports map. The three changes that fixed our publish pipeline were putting types first and default last in every condition block, keeping exactly one implementation of module-level state, and running publint and @arethetypeswrong/cli in CI before npm publish.
Key takeaways
• Node.js 22.12.0 enabled require(esm) by default on the 22.x line, and it has been on by default since Node.js 23.0.0. A CommonJS file can now require() an ES module as long as that module graph contains no top-level await.
• require() of an ES module graph that uses top-level await throws ERR_REQUIRE_ASYNC_MODULE, not ERR_REQUIRE_ESM. The two errors mean different things and have different fixes.
• The exports field in package.json matches conditions in declaration order and the first match wins, so types must come first and default must come last. Adding exports also blocks every subpath you did not list, which surfaces as ERR_PACKAGE_PATH_NOT_EXPORTED in consumers that deep-import.
• The dual package hazard is what happens when one process loads both the CommonJS build and the ES module build of the same package: module-level state exists twice, singletons are no longer single, and instanceof across the two copies returns false.
• Named imports from a CommonJS file work only when cjs-module-lexer can statically detect the export names. Exports assigned in a loop or behind a condition are invisible to it, and the consumer must use a default import instead.
The package that triggered this write-up was a few hundred lines of shared parsing helpers, published as ESM-only because every application in our stack was already ESM. The first team to install it ran a CommonJS service, and it failed at boot with ERR_REQUIRE_ESM. Our first instinct was to add a CommonJS build, which is the standard answer and also the answer that introduces the worst bug in this area.
Can Node.js require() an ES module now?
Yes, on Node.js 22.12.0 and later, and unflagged. require(esm) lets a CommonJS file load an ES module synchronously, returning the module namespace object, with the default export available as the .default property. The feature landed behind the --experimental-require-module flag earlier in the 22.x line, was enabled by default in 22.12.0, and is on by default in Node.js 23 and 24.
The single hard limit is asynchrony. require() is synchronous by definition, so if the ES module graph being required contains a top-level await anywhere, Node.js cannot finish evaluation before returning and throws ERR_REQUIRE_ASYNC_MODULE. That distinction matters when debugging: ERR_REQUIRE_ESM means the runtime is too old or resolution picked a file it will not load, while ERR_REQUIRE_ASYNC_MODULE means the runtime tried and the module itself was the problem. The escape hatch in that case is a dynamic import() inside an async function.
What this changes in practice: ESM-only is now a defensible choice for a new library targeting Node.js 22.12 and above, and the pressure to ship a second CommonJS build drops sharply. What it does not change: bundlers, older runtimes, and tools that still resolve the require condition will keep asking for a CommonJS entry point for a long time.
Why does an exports map resolve to the wrong file?
Because conditional exports are matched top to bottom and the first matching key wins, so ordering is semantics, not style. The types condition must be first or TypeScript may resolve a runtime file before it ever sees the declaration file. The default condition must be last, because it matches everything and shadows any condition placed after it.
A well-formed entry block for a package named @acme/toolkit lists, in order: types pointing at the declaration file, react-server pointing at a server-safe build, import pointing at the ES module build, require pointing at the CommonJS build, and default repeating the ES module build. Two extra details earn their place. Exporting ./package.json explicitly keeps tools that read the manifest at runtime working, because an exports field otherwise makes it unreachable. The react-server condition is what React Server Components resolution uses, and Next.js resolves it for any module imported from a Server Component.
The failure mode teams hit hardest is subpath blocking. Before exports existed, any file inside a package was importable. The moment you add exports, everything not listed is private, and consumers who were deep-importing an internal path get ERR_PACKAGE_PATH_NOT_EXPORTED. That is a breaking change even when nothing else in the package moved.
What is the dual package hazard, and how do we avoid it?
The dual package hazard is the bug where one Node.js process loads both builds of the same package, so every module-level value exists twice. Node.js keeps separate caches for CommonJS and ES modules, so dist/index.js and dist/index.cjs are two distinct modules with two distinct copies of every class, registry, and counter declared at module scope. An object created by the ESM copy of a class returns false for instanceof against the CommonJS copy of the same class.
It is nasty precisely because it is silent. Nothing throws. A validation check fails for a value that is obviously the right type, or a cache that should be shared reports a miss, and the stack trace points at application logic rather than at packaging.
There are three strategies. ESM-only ships one ES module build with no require condition and carries no hazard risk, which is our default for new libraries targeting Node.js 22.12 and above. ESM plus a stateless CommonJS wrapper ships the ES module implementation alongside a small CommonJS file that re-exports it, and carries no hazard provided the wrapper holds no state of its own; we use it when older CommonJS consumers must keep working. Two full builds ship independently bundled CommonJS and ES module outputs, carry high hazard risk, and are only acceptable for pure functions with zero module-level state and no exported classes.
Our rule is simple: if a package exports a class, holds a registry, or memoizes anything at module scope, it ships one implementation. Convenience for CommonJS consumers is worth a wrapper, never a second copy of the logic.
Why can't named exports be destructured from a CommonJS module?
Because Node.js discovers the named exports of a CommonJS file by static analysis, using cjs-module-lexer, and that analysis only sees assignments it can read literally in the source. Exports produced at runtime, such as names assigned inside a for loop, are invisible to it, so the import fails at link time with a syntax error rather than at call time.
If you own the CommonJS file, the fix is to write the assignments out literally, one exports.parse = ... per name, so the lexer can find them. If you do not own it, default-import the module object and destructure afterwards, which always works because it defers the lookup to runtime.
How should TypeScript be configured so output matches the package format?
Set module to nodenext and moduleResolution to nodenext, and let the nearest package.json type field decide how each file is emitted. Under nodenext, TypeScript models Node.js resolution exactly: a .ts file in a type: module package is an ES module, .mts is always ESM, .cts is always CommonJS, and relative imports need file extensions.
Two flags deserve a sentence each. verbatimModuleSyntax tells TypeScript to emit import and export statements exactly as written, dropping only those marked import type, which removes the old class of bugs where the compiler elided an import that existed for a side effect. TypeScript 5.8 taught --module nodenext about require() of an ES module, so a .cts file requiring an ESM dependency is no longer a compile error on runtimes that support it.
The declaration side has its own trap. If you ship a dual package, one .d.ts file cannot describe both formats correctly, because the module kind of a declaration file is inferred the same way as a source file. Dual packages need .d.mts and .d.cts alongside their respective entry points.
How do we verify a package before publishing it?
Run publint and @arethetypeswrong/cli against the packed tarball, then smoke-test both import styles in a real Node.js process. publint lints the manifest for packaging errors such as condition ordering, missing files, and format mismatches between a file extension and its contents. @arethetypeswrong/cli, invoked as attw, checks that type declarations resolve correctly for every consumer configuration, and it catches masquerading types where a CommonJS entry point is described by an ESM declaration file.
We wire both CLIs into the same CI job that runs the build, so a bad exports map fails the pipeline instead of failing someone else's install. That check costs a few seconds and is the only non-negotiable part of this workflow, because packaging errors are invisible in your own repository and only ever appear in a consumer's.
The version we shipped was ESM-only with a thin CommonJS wrapper, one implementation of every class, a types-first condition block, and both linters in CI. The CommonJS service that started all of this now installs it without a build step, and on Node.js 22.12 and above it does not even need the wrapper.
FAQ
Q: Does require(esm) mean we can drop the CommonJS build entirely?
A: Only if every consumer runs Node.js 22.12 or later and no bundler in their toolchain insists on the require condition. For a library with unknown consumers, a thin stateless CommonJS wrapper is cheap insurance and carries no dual package hazard.
Q: What is the difference between ERR_REQUIRE_ESM and ERR_REQUIRE_ASYNC_MODULE?
A: ERR_REQUIRE_ESM means the runtime will not load an ES module through require() at all, which on modern Node.js usually points at resolution picking an unexpected file. ERR_REQUIRE_ASYNC_MODULE means the runtime tried and stopped because the module graph uses top-level await, which require() cannot wait for.
Q: Why did adding an exports field break our consumers?
A: An exports field makes every path you did not list unreachable, so any consumer deep-importing an internal file now gets ERR_PACKAGE_PATH_NOT_EXPORTED. Publish it as a major version, or list the previously reachable subpaths explicitly during a deprecation window.
Q: Is the main field in package.json still needed?
A: Keep main as a fallback for tooling that predates conditional exports. Node.js ignores main whenever an exports field is present, so it costs nothing and buys compatibility with older bundlers and resolvers.
Q: How do we get __dirname in an ES module?
A: Use import.meta.dirname and import.meta.filename, available since Node.js 20.11 and 21.2. For older runtimes, derive it with fileURLToPath(import.meta.url) from node:url.
Further Reading
Full-Stack Engineering
Why a CDN Cache Never Hits: Our Field Notes on Cache-Control, s-maxage, and stale-while-revalidate
A public JSON endpoint reached the origin on every request while the CDN configuration looked correct. These are our field notes on reading x-vercel-cache and Age instead of dashboards, the difference between max-age, s-maxage and stale-while-revalidate, when CDN-Cache-Control beats a single Cache-Control header, and the five response conditions that silently disable edge caching.
Full-Stack Engineering
Multi-Tenant SaaS in the Next.js App Router: How We Handle Subdomain Middleware and the Cache Leak We Caught in Time
We break down how we architect multi-tenant SaaS on the Next.js App Router: resolving tenants in middleware without a database hit per request, tenant-scoped caching, and the Postgres row-level security backstop that fails closed when application code forgets to scope a query.