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.

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. 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

  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.
  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.
  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.
  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.
  5. Pin the condition. Add the dep to optimizeDeps.include (dev) and, for SSR, ssr.noExternal; set build.rollupOptions.output.interop: 'auto' for production.
  6. Verify. Re-run the build and confirm the error is gone and that import meta analysis 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

In-Depth Guides