You maintain a JavaScript package that ships both a CommonJS and an ESM build and keeps a module-level registry of plugins. What can you change so consumers can never end up with two independent registries, and what does each option cost them?
answer
- make a second copy impossible
- one real build, one delegating entry
- duplicate stateless code, never state
- createRequire bridges ESM to the CJS file
- global symbol registry survives duplication
basics
~20 sGuarantee a single implementation. Either ship one build and make the other entry a thin wrapper that re-exports it, move all stateful pieces into one internal file both builds load, or publish a single format. Each trades bundle quality or consumer convenience for guaranteed single-instance state.
solid answer
~50 sThe registry breaks because two build files are two module instances. The fixes all reduce the process to one implementation. First, keep one real build — usually the CommonJS one — and make the ESM entry a thin wrapper that loads it via `createRequire(import.meta.url)` and re-exports its members; ESM consumers then share the CommonJS instance, but you must list named exports statically and you give up ESM-native tree shaking. Second, keep both builds but isolate the state: move the registry into one internal file that both builds load through the same path, so the stateless wrappers can be duplicated harmlessly. Third, publish a single format and drop the other entirely — simplest and safest, at the cost of consumers stuck on the format you dropped. Fourth, redesign so nothing needs shared state, which is only sometimes possible.
code
javascript · 15 linesconst KEY = Symbol.for('acme.validationError');
class ValidationError extends Error {
constructor(message) {
super(message);
this[KEY] = true;
}
}
function isValidationError(value) {
return Boolean(value && value[KEY]);
}
console.log(isValidationError(new ValidationError('bad'))); // true
console.log(Symbol.for('acme.validationError') === KEY); // truego deeper
Know that the fix is to make sure only one copy of the code can be loaded, and that shipping two builds of one library is what creates the risk in the first place.
Explain the thin-wrapper shape — one real implementation, a delegating entry point — and why an ESM wrapper has to list its named exports explicitly instead of spreading the CommonJS object.
Compare the options on what each actually guarantees and what it costs: bundle quality, ongoing discipline, and consumer breakage. Say how you would add a test that loads both entry paths so the fix cannot silently regress.
Treat it as a compatibility decision with a blast radius: dropping a format moves cost onto consumers who never chose it, while keeping both keeps a latent correctness bug alive. Decide when a major release is the right vehicle.
## Why the registry breaks at all A dual-published package ships two build files from one source. Which one a consumer gets depends on how they reached the package, so a process with a mixed dependency graph loads both. Two module instances means two `Map`s. Plugins registered through one are invisible through the other, and the bug reads as "my plugin was ignored" long after the packaging decision that caused it. The cure is not better documentation for consumers — a consumer usually cannot control how a transitive dependency reaches your package. The cure is making a second instantiation impossible. ## Option 1: one implementation, one thin wrapper Keep exactly one real implementation file and make the other entry point delegate to it. In practice the implementation stays CommonJS, because an ESM file can load a CommonJS file synchronously and re-export it: ```js // index.mjs — wrapper, holds no state of its own import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const impl = require('./index.cjs'); export const register = impl.register; export const get = impl.get; export default impl; ``` Now both entry paths reach the same `index.cjs`, so there is one registry no matter how the package was reached. Costs, and they are real: an ESM export list is static, so every named export must be written out by hand and kept in sync — you cannot re-export a CommonJS namespace dynamically. Your ESM consumers receive transpiled CommonJS, so bundlers cannot tree-shake it the way they shake real ESM, and the shipped bundle is larger. `createRequire` is a Node API, so the wrapper is not usable in a browser-only runtime. The mirror-image arrangement — one ESM implementation with a CommonJS wrapper — is harder, because loading ESM from CommonJS was historically asynchronous only. Modern Node can `require()` a synchronous ES module graph (unflagged from Node 22.12, backported to 20.19), which makes this direction viable for consumers on those versions and not for older ones. ## Option 2: keep both builds, isolate the state If you want both builds to stay native for bundle-quality reasons, accept that the *wrappers* may be duplicated and make sure the *state* is not. Move everything identity-bearing — the registry, caches, classes used with `instanceof`, connection singletons — into a single internal file, and have both builds load that one file: ```js // internal/state.cjs — the only stateful file in the package const registry = new Map(); module.exports = { registry }; ``` The CommonJS build requires it directly; the ESM build reaches it through `createRequire`. Duplicating pure, stateless code is harmless, so the remaining duplication costs nothing observable. The cost is discipline: every future addition must be classified as stateful or stateless, and one careless module-level `let` in the wrong file reopens the hazard silently. It is also easy to get wrong if the state file is itself reachable by two paths. ## Option 3: publish one format The hazard exists only because two builds exist. Shipping ESM only removes it outright and is increasingly viable now that `require()` of synchronous ESM works in current Node. Shipping CommonJS only also removes it, and remains loadable from ESM everywhere. Either way you inherit a compatibility cost: consumers pinned to the format you dropped must upgrade their runtime or restructure their loading, and for a widely depended-on package that cost lands on people who never chose it. ## Option 4: design the state away Sometimes shared state is not actually required. A registry can be passed in explicitly rather than kept at module level: `createRegistry()` returns an object the caller owns and threads through. Identity checks can be replaced with structural markers — an error `code` string, or a marker keyed by `Symbol.for('acme.thing')`, which resolves to the same symbol in every copy because the global symbol registry is per-realm, not per-module. This is the most robust answer, since duplication stops being a correctness question at all, but it changes your public API and is not available retroactively for a stable package. ## How to choose Rank by what you can actually guarantee. Single format is the strongest guarantee and the bluntest. Thin wrapper is the standard remedy for an existing dual-published package with state. State isolation is for packages where bundle quality genuinely matters and the team can hold the discipline. Designing the state away is the best long-term answer and the hardest to retrofit. What you must not do is document the hazard and leave it: consumers cannot see which entry path a transitive dependency took.
- Why can the ESM wrapper not simply re-export everything from the CommonJS implementation in one line?Because an ES module's export list is static — the names must be known before evaluation, so you cannot expand an object's runtime keys into exports. Node does apply static analysis to detect named exports from many CommonJS files, but it is best-effort and misses anything assigned dynamically. Writing the export list explicitly is the reliable route, at the cost of keeping it in sync.
- If the package's only shared state is a class used with instanceof, is a wrapper still worth it?Often not. Replacing the identity check with a structural marker — a `code` property, or a key from `Symbol.for()` that both copies resolve identically — makes duplication harmless without touching packaging. That is cheaper and more robust than a wrapper, since it also survives duplicate installs at different versions, which no packaging fix can address.
- What breaks for consumers if you resolve the hazard by dropping the CommonJS build entirely?Anything that must load your package synchronously from CommonJS on a runtime without `require()` of ES modules. Those consumers have to switch to dynamic `import()`, which makes their loading asynchronous and can ripple through their own API, or upgrade their runtime. For a widely used package that is a breaking change and belongs in a major release with a clear migration note.
- How would you stop this regressing after you fix it?Test the mixed case rather than trusting review. A test that loads the package through both entry paths in one process and asserts that a registration made through one is visible through the other fails loudly the moment someone reintroduces module-level state in a duplicated file. Pair it with a rule that new state may only live in the designated single implementation file.
saying these in an interview costs you the question
- Just tell consumers not to mix import and require
- Shipping both builds is always safe if versions match
- A README warning counts as a fix
- Deduping the lockfile removes the hazard
- Any module-level variable is fine as long as it is const