Understanding ESM vs CommonJS in Modern Bundlers

Modern frontend architectures rely on deterministic dependency resolution. This guide isolates the semantic divergence between CommonJS (CJS) and ECMAScript Modules (ESM) so you can prevent runtime resolution failures, keep compile-time analysis intact, and enforce strict interop boundaries inside the bundler. The focus stays on module-format semantics, resolver condition matching, and transpilation mechanics. For the underlying graph-traversal and asset-pipeline model, read Core Concepts of Modern Bundling before tuning interop, and treat everything below as the resolution layer that sits on top of it.

The problem exists because the npm ecosystem is a decade-long sediment of two incompatible module systems that must coexist in a single process. CJS was never specified; it is the emergent behavior of Node’s original loader, where require() is a synchronous function call that reads a file, wraps it in a function scope, and returns whatever the module assigned to module.exports. Because that assignment happens at runtime, the shape of a CJS module’s exports is not knowable until the code runs. ESM is the opposite: import and export are static syntax, the export names are fixed before any code executes, and bindings are live views into the exporting module rather than copied values. A bundler has to reconcile these two worlds inside one dependency graph, and every reconciliation is a place where resolution can silently pick the wrong file or fork a module’s state.

What breaks without a correct interop strategy is rarely a clean compile error. The failure surface is subtle: a library resolves to its CJS build in one part of the graph and its ESM build in another, so you get two copies of the same singleton; a named import that the bundler’s lexer failed to detect becomes undefined at runtime instead of at build time; a dev server cold-starts fine but the production Rollup pass emits a wrapper that defeats tree-shaking and quietly doubles a chunk. These are the expensive bugs — they pass CI, ship, and only surface as “Invalid hook call” or a context that resets on every render. This layer sits directly on top of the resolver: after the bundler has walked the graph but before it emits chunks, it decides, per module, which format to treat each file as and which shim to inject. Getting that decision right is the entire job of this page.

Conditional exports resolution and the dual-package hazard A package.json exports map routing import and require conditions to separate ESM and CJS files, and how loading both creates two copies of module state. package.json "exports" condition matching "exports": { "." : { "import": "./esm/index.js" "require": "./cjs/index.cjs" "types": "./index.d.ts" } } import condition ESM graph, live bindings require condition CJS graph, module.exports state copy A (ESM instance) state copy B (CJS instance) Dual-package hazard Both conditions resolved in one process → two singletons, failed instanceof checks, duplicated React/context state. Mitigation Single source of truth: ship ESM, thin CJS re-export wrapper only.
Figure: conditional exports route import/require to separate files; resolving both in one process forks module state (the dual-package hazard).

Prerequisites

The configuration in this guide assumes Node 18.18+ (20.x recommended for stable require(esm) behavior under the --experimental-require-module flag, default-on from Node 22.12), Vite 5.x or 6.x, Rollup 4.x, and esbuild 0.25.x. The version floor is not arbitrary. Node’s ability to require() a synchronous ESM graph is the single change that removes the majority of dual-package hazards, and it moved from flagged-experimental to default-on across the 20-to-22 line. Pinning below that floor means you are debugging a class of ERR_REQUIRE_ESM failures that the runtime itself has already solved above it. Verify your local toolchain before changing resolver conditions:

# Confirm the toolchain versions this guide targets
node -v          # expect v18.18+ (v20.x preferred)
npx vite -v      # expect vite/5.x or 6.x
npx rollup -v    # expect rollup v4.x
npx esbuild --version  # expect 0.25.x

You also need the two static validators that catch malformed exports maps before they reach a consumer:

# Validate a package's publish-time module surface
npm i -g publint @arethetypeswrong/cli

These two tools cover different failure classes and neither substitutes for the other. publint reads the exports map and checks the runtime surface: whether import and require point at files whose actual format matches the condition name, whether the main/module/exports fields agree, and whether a .js file under a "type": "module" package is really ESM. attw (are-the-types-wrong) checks the type surface — whether a consumer importing under import resolves .d.ts files that describe the ESM shape rather than accidentally pointing every condition at a single CJS-flavored declaration, which is the most common reason a package “works at runtime but the types are wrong.” Run both against a packed tarball, not the source tree, because npm pack is what determines the files a consumer actually receives; a correct exports map that references files excluded by .npmignore is a defect only the tarball reveals.

Toolchain plus two static validators The toolchain is Node 18.18+, Vite 5/6, Rollup 4 and esbuild 0.25; publint and are-the-types-wrong catch malformed exports maps before they reach a consumer. Node 18.18+ / 20require(esm) default @ 22.12 Vite/Rollup/esbuild5/6 · 4 · 0.25 publint + attwcatch bad exports pre-publish Two validators are what stop a malformed exports map from shipping
Figure: publint and attw validate the module surface before a broken exports map reaches consumers.

Core mechanics: how the resolver matches conditions

The architectural shift from CJS to ESM is not merely syntactic; it dictates how toolchains evaluate, cache, and execute code. CJS uses synchronous require() calls and a mutable module.exports object evaluated at runtime. ESM uses static import/export declarations resolved at parse time, with live bindings and native top-level await.

Resolution hinges on the package.json exports field and the active condition set. When a bundler or Node resolves a bare specifier, it walks the exports object top-to-bottom and selects the first key whose condition is active. Order matters: import must precede require, and types must come first for TypeScript to resolve declarations. The default Node condition order is ["node-addons", "node", "import", "require", "default"]; bundlers inject extra conditions such as module, browser, and development.

The mechanism to internalize is that exports is an ordered object, and JavaScript object key order is insertion order. The resolver does not score conditions by specificity or pick the “best” match — it takes the first key it encounters that is present in the active condition set, and stops. This is why types must be the first key: TypeScript adds types to its condition set, but if import appears before types in the object and import is also active, the resolver returns the .js file and never considers the declaration entry. The symptom is a package that runs correctly but reports any for every import, and the cause is purely key order in a file no one thought to check. The same trap applies to default, which by convention is last precisely because it is unconditionally active — placed anywhere but the bottom, it short-circuits every condition below it.

Condition sets are additive and consumer-controlled. Node evaluates with a fixed base set; a bundler layers its own conditions on top, which is why the same package can resolve to different files under node, under Vite’s browser build, and under Vite’s SSR build. browser and node are mutually exclusive by intent — a package uses them to ship a fetch-based implementation to the browser and an http-based one to Node from the same specifier. development and production let a library ship an unminified build with invariant checks to dev and a stripped build to prod; Vite sets one or the other based on the command, so a condition map that omits production and only lists development will fall through to default in a prod build, occasionally shipping the dev variant to users. Because the sets compose, an exports map is only correct relative to the union of every consumer’s conditions, which is why the validators above matter more than local testing.

Conditions match top-to-bottom, first hit wins The resolver walks the exports block top to bottom: types first for TypeScript, then import for ESM consumers, then require for CommonJS, then default last; the first active condition wins. exports["."] — walked top to bottom "types" → ./index.d.ts (first, for tsc) "import" → ./esm/index.js (ESM) "require" → ./cjs/index.cjs (CJS) types after import → skipped A CJS wrapper (__toESM(require)) is a black box that blocks member pruning.
Figure: order is semantics — types after import is silently skipped, exactly as in library mode.
// package.json — conditional exports, correct ordering
{
  "name": "@scope/widget",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",   // must be first so tsc resolves it
      "import": "./dist/index.js",     // ESM consumers (Vite, Node import)
      "require": "./dist/index.cjs",   // CJS consumers (Node require)
      "default": "./dist/index.js"     // fallback condition, always last
    },
    "./package.json": "./package.json"
  }
}

ESM’s static structure lets bundlers prune unused exports without executing the module, which directly powers tree-shaking and dead-code elimination. CJS, with dynamic require() and reassignable exports, forces conservative inclusion or an aggressive lexer pass. When a bundler wraps a CJS module for an ESM consumer it emits a __toESM(require("...")) shim and an __esModule marker; that wrapper is a black box for export analysis and blocks elimination of unused members.

How it works under the hood

The interop shim exists because the two systems disagree on what “the default export” means. In CJS, module.exports is a single value with no notion of a distinguished default. In ESM, import x from 'pkg' binds x to the module’s default export specifically. To bridge them, transpilers agreed on a marker: a transpiled ESM-to-CJS module sets Object.defineProperty(exports, '__esModule', { value: true }) so that a later consumer can tell “this CJS object was really an ES module” from “this is a hand-written CJS module whose entire value is the default.” The __toESM helper checks that marker at runtime: if __esModule is present it passes the namespace through; if not, it synthesizes a namespace whose default is the whole module.exports object and copies enumerable own keys as named exports. That runtime branch is the reason CJS interop cannot be fully resolved at build time — the helper’s behavior depends on a property that may only exist after the module has executed.

The consequence for tree-shaking is direct. A pruner works by proving that a binding is never read, which requires knowing the complete set of a module’s exports statically. __toESM returns an object assembled at runtime from module.exports, so the pruner cannot enumerate its members and must retain the whole thing. This is why one CJS dependency deep in an otherwise-ESM graph can pin kilobytes of dead code that would otherwise be dropped: the wrapper is an opaque boundary, and everything reachable through it is retained conservatively. The cjs-module-lexer that Node and Rollup use mitigates this by scanning CJS source for common export patterns (exports.foo = ..., module.exports = { foo }, re-exports via Object.defineProperty) so named imports can be bound statically — but it is a heuristic lexer, not an evaluator, so any export produced by a computed key or a loop is invisible to it, which is the root of the named-export-not-found failure covered below.

The imports field (subpath imports, distinct from exports) lets a package remap internal specifiers per condition — useful for swapping a Node implementation for a browser one without touching call sites:

// package.json — internal subpath imports keyed by condition
{
  "imports": {
    "#crypto": {
      "node": "./src/crypto.node.js",
      "browser": "./src/crypto.browser.js",
      "default": "./src/crypto.browser.js"
    }
  }
}

Subpath imports must begin with # — that prefix is what tells the resolver a specifier is package-internal and should be looked up in imports rather than treated as a bare dependency or a relative path. The advantage over a build-time alias plugin is that imports is resolved by Node and every bundler natively, with no plugin, so the same source runs correctly under node --test, under Vite, and under a downstream consumer that bundles your package from source. The failure mode to watch for is a missing default branch: if a consumer’s active condition set matches none of the listed keys, resolution throws ERR_PACKAGE_IMPORT_NOT_DEFINED, so a default entry is effectively mandatory unless you can prove every consumer supplies one of the named conditions. Keep imports for genuine platform splits; using it as a general path-alias mechanism couples your internal layout to the resolver and tends to surprise anyone reading the call site.

Configuration & CLI reference

Each toolchain resolves format differently. Vite delegates to esbuild for dependency pre-bundling and to Rollup for production builds, so interop requires explicit resolver overrides on both sides. The blocks below are complete and runnable.

The reason a single Vite override is rarely enough is that dev and build are two different engines with two different resolvers, and a dependency can behave differently under each. In dev, esbuild pre-bundles your node_modules into a small number of ESM files, converting any CJS it finds along the way; the conversion is aggressive and forgiving. In build, Rollup with @rollup/plugin-commonjs performs a stricter, statically-analyzed conversion that can reject patterns esbuild waved through. The practical consequence is the “works in dev, breaks in build” report: a CJS dependency whose exports esbuild’s tolerant lexer detected but Rollup’s did not. Configuring interop means keeping both engines’ views of a dependency aligned, which is why the settings below always come in pairs.

The interop knob per toolchain Vite uses resolve.conditions plus optimizeDeps and ssr.noExternal; Rollup uses @rollup/plugin-commonjs with requireReturnsDefault; esbuild auto-detects with --format and --platform flags. Viteconditions + optimizeDepsssr.noExternal Rollupplugin-commonjsrequireReturnsDefault esbuild--format --platformauto-detect + wrap Vite needs overrides on both the esbuild and Rollup sides
Figure: because Vite uses two engines, interop must be set on both the pre-bundle and build sides.

Vite — conditions, pre-bundling, SSR externalization

Vite — conditions, pre-bundling, SSR externalization

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';

export default defineConfig({
  resolve: {
    // Prioritize ESM entry points over CJS fallbacks during resolution
    conditions: ['module', 'browser', 'development|production', 'default'],
  },
  optimizeDeps: {
    // Force esbuild to convert these CJS deps to ESM at dev cold-start
    include: ['cjs-heavy-lib', 'another-cjs-dep'],
    // Leave pure-ESM packages alone (no pre-bundle indirection)
    exclude: ['esm-native-lib'],
  },
  ssr: {
    // Bundle CJS deps for SSR instead of externalizing them to require()
    noExternal: ['node-cjs-dep'],
  },
  build: {
    rollupOptions: {
      output: {
        // 'auto' emits synthetic default wrappers for CJS; 'compat' is stricter
        interop: 'auto',
      },
    },
  },
});

Read resolve.conditions top-to-bottom the same way the resolver reads exports: it is the priority list Vite hands to the resolver, and putting module before browser before default is what makes an ESM entry win over a CJS main when a package offers both. The optimizeDeps split is the highest-leverage knob. include forces esbuild to pre-bundle a dependency even when Vite would not have discovered it through static import scanning — necessary for deps loaded dynamically or reached only through a CJS require chain, because an un-pre-bundled CJS file served raw to the browser throws “Cannot use import statement outside a module” on its first ESM-looking line. exclude does the reverse for a dependency that is already clean ESM: pre-bundling it adds a layer of __toESM indirection and an extra file hop for nothing, so excluding it keeps the module identity intact and avoids a needless conversion. ssr.noExternal is a third axis entirely — it governs whether Vite bundles a dependency into the SSR output or leaves it as a bare require/import for the Node runtime to resolve, and it is the correct fix when an ESM-only dependency crashes an SSR build with ERR_REQUIRE_ESM. For the full interop decision tree in Vite, the dedicated ESM/CJS interop in Vite guide walks each optimizeDeps / ssr.noExternal knob.

Rollup — the CommonJS plugin

// rollup.config.js — Rollup 4.x
import commonjs from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';

export default {
  input: 'src/index.ts',
  output: { dir: 'dist', format: 'es', interop: 'auto' },
  plugins: [
    nodeResolve({ exportConditions: ['module', 'import', 'default'] }),
    commonjs({
      // 'auto' returns the default export only when there is no __esModule marker
      requireReturnsDefault: 'auto',
      // Let CJS files that already contain import/export be parsed as mixed
      transformMixedEsModules: true,
    }),
  ],
};

Plugin order is load-bearing here: nodeResolve must run before commonjs so that bare specifiers are resolved to on-disk paths before the CommonJS transform decides how to wrap them. The exportConditions array passed to nodeResolve is Rollup’s equivalent of Vite’s resolve.conditions — it is the condition set the resolver matches against, and omitting module/import there is a common reason Rollup picks a package’s CJS main when an ESM build was available. requireReturnsDefault is the setting that most often decides whether a converted require() yields the module’s namespace or just its default. The 'auto' value inspects the target for an __esModule marker and returns the default only when the marker is absent, which is correct for the mixed ecosystem; the alternatives (false, true, 'preferred') exist for dependencies that lie about their marker, and reaching for them is a signal that the dependency’s own exports map is the real defect. transformMixedEsModules covers the increasingly common file that has been partially migrated — it contains both require() and import — which the plugin would otherwise refuse to treat as CJS at all.

esbuild — format and platform flags

# esbuild 0.25.x, Node 20+ — bundle to ESM, keep native addons external
esbuild src/index.ts --bundle --format=esm --platform=node \
  --external:*.node --tree-shaking=true --metafile=build/meta.json

The two flags that determine interop behavior are --format and --platform, and they interact. --platform=node sets the default condition set to include node and switches the main field precedence toward CommonJS-friendly resolution; --platform=browser (the default) prefers browser and module. --format=esm forces the output to ES module syntax regardless of input, which is what triggers the __toESM wrapping for any CJS input in the graph. Native addons — files loaded through require('./foo.node') — cannot be bundled because they are compiled binaries, so --external:*.node marks them for runtime resolution; leaving that out produces a build that fails at bundle time with an unrecognized-file error. --metafile=build/meta.json is not optional in practice: it is the only way to see, after the fact, which inputs esbuild classified as CJS and which it left as ESM, and it is the input the performance-measurement step below depends on.

An esbuild subtlety that bites SSR and CLI builds specifically: esbuild does not read package.json type to decide the output extension, so a bundle emitted as ESM into a .js file inside a package without "type": "module" will be re-parsed by Node as CJS and throw at the first import. When bundling to ESM for Node, emit .mjs or set the output package’s type to module — the extension and the type field, not the --format flag, are what Node consults at load time.

Numbered workflow: diagnose and pin an interop boundary

Six-step interop diagnosis Reproduce against a clean cache, trace which exports condition the resolver selected, inspect the emitted __toESM shim, validate the package surface with publint, pin the condition via optimizeDeps and ssr.noExternal, then verify the dep resolves once. 1 clean cache--force 2 trace resolverwhich condition 3 inspect shim__toESM? 4 validatepublint 5 pin conditionoptimizeDeps 6 verify oncenot twice
Figure: step 6 — resolves once, not twice — is the check that rules out the dual-package hazard.
  1. Reproduce against a clean cache. Delete node_modules/.vite and run npx vite dev --force so a stale pre-bundle cannot mask the real resolution. Vite fingerprints its pre-bundle by a hash of lockfile, config, and a handful of env inputs, and it will happily serve a cached deps/ directory that predates the change you are debugging — so the first symptom to rule out is that you are looking at a build from before the problem existed. --force invalidates that cache; deleting the directory is the belt-and-braces version for when the fingerprint failed to notice a transitive change. Confirm the reproduction is real by watching the terminal for the “Pre-bundling dependencies” line, which only prints on a cold optimize.

  2. Trace the resolver. Run NODE_DEBUG=module node server.js (or npx vite dev --debug) and read which exports condition was selected for the failing package. The value of tracing rather than guessing is that the resolver’s choice is often counterintuitive — a package you assumed was ESM may be resolving through require because your condition list put require before import, or because the package’s own exports lists them in that order. The debug output names the exact file the resolver landed on; compare that path against the package’s dist/ layout to see whether you got the .js (ESM) or .cjs (CJS) build. If the selected file is not the one you expected, the fix is a condition-order change, not an interop shim.

  3. Inspect the emitted shim. Grep the dev pre-bundle for the wrapper: grep -rl "__toESM(require" node_modules/.vite/deps/. A match means the dep is CJS and was converted. This step distinguishes the two root causes that produce identical symptoms: a genuinely-CJS dependency that was wrapped, versus an ESM dependency that resolved to a stale or wrong file. If the grep returns the failing dependency’s chunk, the dependency is CJS and your job is to make the conversion correct; if it does not, the problem is upstream in resolution and step 2 is where the answer lives. Reading the wrapped chunk directly also shows you which named exports the lexer managed to hoist, which previews whether step 6 will find them.

  4. Validate the package surface. Run npx publint and npx @arethetypeswrong/cli --pack against the dependency (or your own package) to confirm import/require/types ordering and detect a masked dual entry. When the failing package is a third-party dependency you do not control, this step converts “my build is broken” into a specific, reportable defect: publint will name the exact exports key that points at the wrong format, and that output is what belongs in the upstream issue. When the failing package is your own, treat any publint error as a release blocker — a malformed exports map that ships is a defect every downstream consumer inherits and none of them can fix.

  5. Pin the condition. Add the dep to optimizeDeps.include (dev) and, for SSR, ssr.noExternal; set build.rollupOptions.output.interop: 'auto' for production. This is the point where you stop diagnosing and commit a fix that survives the dev/build engine split described earlier — optimizeDeps.include handles esbuild’s view, output.interop handles Rollup’s, and ssr.noExternal handles the Node runtime’s. Applying only one of the three is the most common way a fix “works locally but breaks in CI,” because CI usually runs the build path, not the dev path you tested against.

  6. Verify. Re-run the build and confirm the error is gone and that import meta analysis still reports the dep once, not twice. The “once, not twice” check is the one that rules out the dual-package hazard, and it is easy to skip because the original error being gone feels like success. Read the --metafile output or the visualizer and search for the dependency’s name: two entries, one under an ESM path and one under a CJS path, means you silenced the symptom while forking the module’s state — a strictly worse outcome than the original crash because it now fails at runtime instead of build time.

Debugging & failure modes

Four ESM/CJS interop failure modes Cannot use import statement outside a module means a CJS file is evaluated as ESM; ERR_REQUIRE_ESM means a CJS module required an ESM-only dep; the dual-package hazard loads a package twice; and named-export-not-found is a cjs-module-lexer detection gap. Cannot use import outside a module add to optimizeDeps.include ERR_REQUIRE_ESM dynamic import() / ssr.noExternal dual-package hazard single ESM impl + thin CJS re-export named export not found interop: 'auto' + default destructure
Figure: four symptoms — a format error, a require error, a state-forking hazard, and a lexer gap.

SyntaxError: Cannot use import statement outside a module

A CJS file (or a file in a package without "type": "module") is being evaluated as ESM. Add the package to optimizeDeps.include so esbuild rewrites it, or correct the package’s exports/type fields. The symptom is precise about location: the error names a file and a line, and that line is almost always the first import statement in a file the loader decided was CJS. The root cause is a mismatch between the file’s contents (ESM syntax) and the loader’s classification (CJS, because the nearest package.json has no "type": "module" or the exports condition routed here through require). The fix depends on ownership — for a dependency, pre-bundling through optimizeDeps.include lets esbuild transpile the syntax away; for your own code, the correct fix is the type field or the file extension, because pre-bundling your own source masks a defect you should be fixing at the root. Confirm the fix by deleting the pre-bundle cache and reloading: the error should not return on a cold start.

ERR_REQUIRE_ESM

A CJS module called require() on an ESM-only dependency in Node. Either upgrade the consumer to ESM, use dynamic await import(), or — on Node 22.12+ — rely on the default-on synchronous require(esm) support. For SSR, add the dep to ssr.noExternal so Vite bundles it rather than handing it to Node’s require. The distinguishing detail of this error versus the syntax error above is that it originates in Node’s loader, not the bundler — the stack trace bottoms out in an internal module such as node:internal/modules/cjs/loader. That tells you the failing require survived bundling and reached the runtime, which is only possible for an externalized dependency; a bundled one would have been converted. This is why ssr.noExternal is the SSR fix: it moves the dependency from “externalized, resolved by Node’s require” to “bundled, converted by Vite.” Confirm by checking that the dependency no longer appears in the externalized set — in an SSR build, grep the server output for a bare require('the-dep'); its absence means the dependency was inlined.

The dual-package hazard

When both the import and require conditions resolve in the same process, the package loads twice and forks its module state. Symptoms: instanceof checks fail across the boundary, React throws “Invalid hook call” from a duplicated copy, or a context singleton is silently doubled. Mitigation: publish a single implementation (ESM) and make the CJS entry a thin module.exports = require('./esm wrapper') re-export, or deduplicate via resolve.dedupe in Vite. The reason this hazard is the most dangerous entry in this list is that it produces no build error at all — both copies load successfully, so nothing fails until two pieces of code that assume a shared singleton meet at runtime. The instanceof failure is the clearest tell: an object created by copy A fails an instanceof check against copy B’s class, because the two classes are distinct objects despite identical source. For a library author, the durable fix is to never ship two full implementations — one real build plus a re-export means there is only one copy of the module state to fork. For a consumer stuck with a dual-published dependency, resolve.dedupe: ['the-dep'] forces every import of that name to the same physical file. Confirm with the step-6 check above: the dependency must appear exactly once in the metafile.

default is not exported by ... / named export not found

Rollup or cjs-module-lexer failed to statically detect a CJS export. Set output.interop: 'auto' and import the default then destructure. The full repro and fix live in resolving named-export-not-found errors. The mechanism is the lexer limitation described under “How it works under the hood”: cjs-module-lexer recognizes a fixed vocabulary of export patterns, and a CJS module that assigns its exports through a computed key, a loop, or a helper function produces exports the lexer cannot see. Rollup then rejects import { thing } from 'pkg' at build time because, as far as static analysis knows, thing is not exported. The workaround imports the default (the whole module.exports object, which does exist) and reads the member off it at runtime:

// consumer.js — bypass the lexer gap for a dynamically-built CJS export
// Named import fails: the lexer never saw `thing` on module.exports.
import pkg from 'legacy-cjs-lib'; // default = the whole module.exports
const { thing } = pkg;           // read the member at runtime instead
export { thing };

Confirm the fix resolves the value rather than papering over it: log typeof thing after the destructure. If it is undefined, the member genuinely does not exist and the problem is a wrong import name, not the lexer gap — the two failures share an error message but have opposite fixes.

Performance impact & measurement

Migrating CJS to ESM: three measurable wins Moving CJS-heavy trees to pure ESM reduces production bundle size by 15 to 30 percent, drops V8 parse and compile time by 20 to 40 percent, and cuts Vite dev cold-start by 35 to 50 percent by removing __toESM wrappers. bundle size−15–30% parse / compile−20–40% Vite cold-start−35–50% Each __toESM wrapper adds ~1.2 KB and disables member pruning
Figure: removing the CJS wrappers pays off in size, parse time, and dev cold-start together.

Migrating CJS-heavy dependency trees toward pure ESM typically yields a 15–30% reduction in production bundle size and a 20–40% drop in V8 parse/compile time, because each __toESM wrapper adds roughly 1.2 KB per module and disables member-level pruning. Proper pre-bundling of problem CJS deps cuts Vite dev cold-start by 35–50% and removes ERR_REQUIRE_ESM crashes during HMR. Measure it with the esbuild --metafile output (feed build/meta.json to a visualizer) and with vite build followed by rollup-plugin-visualizer to confirm no CJS wrapper survives in a hot path.

Treat these ranges as symptoms to investigate, not guarantees to expect — the size win is proportional to how much dead code the wrapper was pinning, so a CJS dependency you use fully sheds almost nothing, while one you import a single function from can shed most of its weight once the wrapper is gone and member pruning is re-enabled. The parse/compile win is a second-order effect of the same mechanism: less shipped code is less code for V8 to parse, and eliminating the wrapper’s runtime __esModule branch removes a small deopt-prone path from the hot module. The measurement discipline that matters is to diff the metafile before and after, not to trust the visualizer’s totals in isolation. The metafile records, per input, the byte count and the imports; searching it for __toESM or for a dependency appearing under two paths is how you prove a specific wrapper was removed rather than merely observing that the bundle got smaller for some unrelated reason.

When not to dual-publish

Shipping both an ESM and a CJS build is the reflex, but it is the wrong default for most libraries. Every dual-published package is a standing dual-package-hazard risk, doubles the surface publint and attw must validate, and doubles the build matrix. Publish CJS only when you have concrete evidence of a consumer that cannot load ESM — an older Node service pinned below the require(esm) floor, or a build tool with no ESM path. For a library whose consumers are exclusively bundlers and Node 22.12+, ship ESM alone; the require(esm) support means even require()-based consumers can load it, and the single build cannot fork its own state. When you must ship both, make one of them authoritative and the other a thin re-export, so there is exactly one copy of every stateful value regardless of which condition a consumer resolves.

Compatibility matrix

Interop override by consumer Vite dev converts CJS via optimizeDeps and dedupe; Vite build uses plugin-commonjs and interop auto; Vite SSR uses ssr.noExternal; and Node 22.12 with require(esm) default-on reduces the dual-package hazard entirely. Vite dev / buildoptimizeDeps · plugin-commonjs · interop auto Vite SSR (Node)ssr.noExternal for mixed deps Node 22.12+ require(esm) default-onreduces the dual-package hazard for sync graphs
Figure: the override depends on the consumer; Node 22.12's require(esm) removes most of the hazard for sync graphs.
Consumer / loader Pure ESM dep Pure CJS dep Dual-package dep Required override
Vite dev (esbuild pre-bundle) native converted via optimizeDeps risk of double-load optimizeDeps.include, resolve.dedupe
Vite build (Rollup) native @rollup/plugin-commonjs interop: 'auto' output.interop
Vite SSR (Node) native externalized to require hazard if mixed ssr.noExternal
Node 18.x import native __esModule interop import/require split correct exports order
Node 22.12+ require(esm) default-on native reduced hazard none for sync graphs
esbuild --format=esm native auto-detected, wrapped wrapper per condition --external:*.node

The matrix is organized by consumer because the correct override is a property of who is loading the dependency, not of the dependency itself. The same dual-published package needs optimizeDeps.include under Vite dev, output.interop under Vite build, and ssr.noExternal under Vite SSR — three different knobs for one package, because three different resolvers are doing the loading. The one row that changes the calculus is Node 22.12+: with require(esm) default-on, a synchronous ESM graph no longer forces a consumer into the import/require split that creates the hazard, so a library targeting only that floor can drop its CJS build and delete an entire column of overrides from every downstream consumer’s config. That is the direction the ecosystem is moving, and it is why the “ship ESM only” advice above is safe for an increasing share of packages.

CI integration

The failure modes in this guide are cheap to catch in CI and expensive to catch in production, so gate on them. Run the two validators against a packed tarball on every release, and fail the job on any error rather than treating their output as advisory. The check below is the minimum that would have caught every symptom on this page — a malformed exports map, a wrong condition order, or a type surface that resolves to the wrong declarations:

# ci/validate-package.sh — run in the release job, before publish
# Tools: publint + @arethetypeswrong/cli (installed as devDependencies)
set -euo pipefail
npm run build                         # produce dist/ the exports map points at
npm pack --silent                     # emit the exact tarball a consumer receives
npx publint --strict                  # fail on any exports/format mismatch
npx @arethetypeswrong/cli --pack      # fail on wrong .d.ts resolution per condition

Two habits make the gate reliable. Build before you validate, because both tools inspect the files exports references and an empty or stale dist/ produces false passes. And validate the tarball (npm pack) rather than the working tree, because .npmignore and the files field decide what actually ships — a correct exports map that points at files excluded from the tarball passes a source-tree check and fails for every real consumer. For an application rather than a library, the equivalent CI gate is the step-6 verification: assert that no dependency appears twice in the build metafile, which turns the dual-package hazard from a runtime surprise into a failed build.

In-Depth Guides