As the maintainer of a widely depended-on JavaScript library, how would you decide whether to ship an ESM build only, a CommonJS build only, or both, given the dual package hazard?
answer
- correctness question before compatibility question
- audit the surface for identity dependence
- duplication-tolerant versus single-instance
- unenforceable guidance is not a strategy
- format change is a major release
basics
~20 sDecide by what your public API depends on. If correctness rests on shared state or class identity, ship a single implementation so two copies are impossible. If the library is stateless, dual builds are a bundle-size convenience with no correctness risk, and the choice becomes purely a compatibility question.
solid answer
~50 sI start by auditing the API surface for identity dependence: module-level registries, connection pools, caches, classes documented for `instanceof`, unique symbols. If any exist, two copies is a correctness bug my consumers cannot prevent, because a transitive dependency chooses its own entry path — so I ship one implementation, either a single format or one real build behind a thin delegating entry. If the library is genuinely stateless, dual builds cost only bytes and both formats stay safe. The second input is the consumer population: how many are on runtimes that cannot load ESM synchronously, and can they realistically move. Modern Node can `require()` a synchronous ES module graph, which makes ESM-only far less hostile than it was, but bundler and older-runtime consumers still exist. Whatever I choose, the change ships in a major release with an explicit migration note.
go deeper
Know that a library can be published in more than one module format, and that shipping two builds is what makes a double-load possible in the first place.
Be able to say which library features make duplication a correctness bug rather than wasted memory — shared registries, singletons, classes checked by identity — and which make it harmless.
Show how you would run the audit and pick a remedy, and explain why telling consumers to avoid mixing entry paths cannot work when the mixing happens inside a transitive dependency.
Own the trade explicitly: correctness versus bundle quality versus consumer compatibility, sized against the real dependent population, shipped as a major release, and encoded in a test and a decision record so the choice outlives you.
## Frame the decision correctly The common framing is "which formats should I support?" That is the wrong first question, because it treats the hazard as a compatibility matter. The right first question is: **can my library tolerate being instantiated twice in one process?** If it can, dual builds are a packaging convenience. If it cannot, dual builds are a latent correctness bug that your consumers have no way to prevent, and the format question is downstream of fixing that. ## Input 1: audit the API for identity dependence Go through the public surface and mark anything whose contract depends on identity or shared state: - module-level registries, plugin tables, caches, memoisation maps; - singletons: a connection pool, a client, a scheduler, an event bus; - classes documented for `instanceof`, or errors consumers are told to check that way; - unique symbols from `Symbol()` used as protocol keys between your modules and consumers; - `WeakMap`s keyed by objects that cross the API boundary. Each entry is a place where two copies produce wrong answers rather than merely wasted memory. A library with none of these is *duplication-tolerant*: two copies waste a little memory and start-up time and nothing else. This audit also reveals a cheaper move than any packaging change. Much identity dependence is incidental — an error hierarchy that could expose a stable `code` string, a symbol that could come from `Symbol.for()` and therefore resolve identically in every copy, a registry that could be created by the caller and passed in. Removing identity dependence makes the packaging question stop mattering, and it also protects you against duplicate installs at different versions, which no packaging choice can fix. ## Input 2: the consumer population Count, do not guess: what fraction of your dependents load you from CommonJS, and what runtimes are they on. The relevant fact is that current Node can `require()` an ES module whose graph is fully synchronous — unflagged from Node 22.12 and backported to 20.19 — so ESM-only is materially less hostile than it was a few years ago. But it is not free. A consumer whose graph includes top-level `await` cannot be loaded that way; older runtimes cannot at all; and some build pipelines still handle a CommonJS entry more predictably. For a library with thousands of dependents, a portion of that tail will simply not move, and the cost of the decision lands on people who never made it. ## Input 3: what you can actually enforce A choice you cannot enforce is not a strategy. "Consumers should not mix `import` and `require` for our package" is unenforceable — the mixing usually happens inside a transitive dependency the consumer does not control. If your library needs single-instance semantics, it must be structurally impossible to get two, not merely discouraged. That is what pushes stateful libraries toward one implementation regardless of how many entry points they expose. ## Putting it together - **Stateless library, broad consumer base:** dual builds are fine and give bundlers the ESM they prefer. Keep the audit in your review checklist so nobody adds module-level state later. - **Stateful library:** ship one implementation. Either a single format, or one real build with the other entry delegating to it. Accept the bundle-quality loss; correctness outranks bytes. - **New library, modern consumers:** ESM-only is the cleanest default. There is no hazard, no wrapper, no export list to maintain, and the format question never returns. - **Established library considering a change:** it is a breaking change. Major version, migration note, and a deprecation window long enough that dependents can move. ## The organisational half Whichever route you take, encode it so it survives you. A test that loads the package through both entry paths in one process and asserts shared state stays shared catches a regression the day it lands, which no review checklist reliably does. A written rule about where state may live gives contributors something concrete to follow. And a decision record explaining *why* the format choice was made stops the next maintainer from casually adding a second build to satisfy one loud consumer. ## What a strong answer sounds like It separates correctness from convenience, names the audit that decides which one you are in, quantifies the consumer cost instead of asserting it, and admits the trade honestly: single-implementation packaging costs bundle quality, single-format publishing costs compatibility, and doing nothing costs your users a bug they cannot diagnose.
- Your library is stateless today. What would you put in place so a future contributor does not quietly reintroduce the hazard?A test that loads the package through both entry paths in one process and asserts that state set through one is visible through the other. It passes trivially while the library is stateless and fails the first time someone adds a module-level registry. Back it with a written rule naming the one file where state is permitted, so the fix is obvious when it fires.
- How would you actually measure the cost of dropping the CommonJS build rather than guessing at it?Sample the real dependent population: which packages depend on you, how they load you, and which runtimes their own support ranges allow. Registry download data by version and issue history give a rough shape. The number that matters is not how many use CommonJS, but how many are blocked from moving — those are the ones a major release strands.
- Is there a case where you would knowingly ship both builds even though your library holds state?Only with the state confined to a single internal file that both builds load, so the duplicated code is genuinely stateless. That is a deliberate trade of ongoing discipline for bundle quality, and it is defensible when the package is large and widely bundled into browser payloads. Without that confinement, shipping both builds while holding state is knowingly shipping a bug.
saying these in an interview costs you the question
- Always ship both builds so nobody is left out
- Documenting the hazard is enough for consumers
- ESM-only is never viable for a real library
- Format choice is a patch-level change
- Bundle size matters more than one shared instance