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.
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. 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
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.
// 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.
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"
}
}
}
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.
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',
},
},
},
});
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,
}),
],
};
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
Numbered workflow: diagnose and pin an interop boundary
- Reproduce against a clean cache. Delete
node_modules/.viteand runnpx vite dev --forceso a stale pre-bundle cannot mask the real resolution. - Trace the resolver. Run
NODE_DEBUG=module node server.js(ornpx vite dev --debug) and read whichexportscondition was selected for the failing package. - 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. - Validate the package surface. Run
npx publintandnpx @arethetypeswrong/cli --packagainst the dependency (or your own package) to confirmimport/require/typesordering and detect a masked dual entry. - Pin the condition. Add the dep to
optimizeDeps.include(dev) and, for SSR,ssr.noExternal; setbuild.rollupOptions.output.interop: 'auto'for production. - Verify. Re-run the build and confirm the error is gone and that
import metaanalysis still reports the dep once, not twice.
Debugging & failure modes
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.
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 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.
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.
Performance impact & measurement
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.
Compatibility matrix
| 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 |
Related
- Core Concepts of Modern Bundling — the resolution and graph model this interop layer sits on top of.
- How to configure ESM and CJS interop in Vite — exact
optimizeDeps,ssr.noExternal, and Rollupinteropsettings. - Resolving “named export not found” errors — repro and fix for the
cjs-module-lexerdetection gap. - Tree-Shaking Mechanics and Dead Code Elimination — why CJS wrappers block static pruning.