ES Modules vs CommonJS in Bundlers

Without a strict ESM resolution strategy, bundlers fall back to CommonJS interop wrappers that inflate production bundles by 8–12%, add 15–35% to initial AST parse time, and make tree-shaking statistically unpredictable — dead code from a single CJS dependency can survive into every downstream chunk. The fix is not a runtime patch; it is a compile-time contract enforced through bundler configuration, package.json exports conditions, and CI gates.

This page is part of the JavaScript Build Pipeline & Module Resolution Fundamentals reference, which covers the full range of bundler mechanics from format normalization through to chunk graph topology.


The Core Problem: Static Contracts vs Runtime Assignments

ESM and CJS differ at the level of the JavaScript specification. import and export are syntactic declarations that the parser resolves before any code executes. require() is a function call evaluated at runtime, and its argument can be any expression:

// ESM — static, analysable at parse time
import { debounce } from 'lodash-es';

// CJS — dynamic, cannot be statically resolved
const utils = require(`./plugins/${pluginName}`);

This distinction has direct consequences for how bundlers build dependency graphs. With ESM, the bundler can walk the full import graph in a single parse pass, prune unused exports, and produce deterministic chunk boundaries. With CJS, the bundler must conservatively include every possible require() target — including files that will never be accessed at runtime.

The diagram below shows how the two resolution models diverge inside a bundler’s compilation pipeline:

ESM vs CJS Bundler Resolution Two parallel pipelines. The ESM path goes: Source → AST Parse → Static Import Graph → Scope Hoisting + Tree-Shaking → Optimised Chunk. The CJS path goes: Source → AST Parse → Runtime require() Scan → Conservative Inclusion (all targets) → Interop Wrappers → Chunk with Dead Code. ESM — Static Resolution CJS — Dynamic Resolution Source Files AST Parse (import/export declarations) Static Dependency Graph (complete) Scope Hoisting + Tree-Shaking Optimised Chunk (~95% dead code removed) Source Files AST Parse (require() calls scanned) Conservative Inclusion (all targets) Interop Wrappers Injected (+1–3 KB/mod) Chunk with Dead Code (~40% removed)

Webpack 5: Configuring ESM-First Resolution

Webpack 5 reconciles mixed module formats through an internal interop system. When a CJS module is imported across an ESM boundary, Webpack generates __webpack_require__.n wrappers and attaches __esModule: true to the export namespace. These wrappers are functional but carry two penalties: they prevent the concatenateModules (scope hoisting) optimisation from inlining the module, and they introduce synchronous runtime checks that fragment chunk boundaries.

Configure resolve.conditionNames and optimization to enforce ESM-first resolution across your application:

// webpack.config.js — Webpack 5
module.exports = {
  experiments: {
    topLevelAwait: true,       // Enable async module boundaries without synthetic wrappers
  },
  resolve: {
    fullySpecified: false,     // Keep false for broad node_modules compatibility
    mainFields: ['module', 'browser', 'main'],
    conditionNames: ['import', 'module', 'require', 'default'],
  },
  optimization: {
    concatenateModules: true,  // Scope hoisting — only activates on ESM modules
    usedExports: true,         // Mark unused exports for tree-shaking
    sideEffects: true,         // Respect package.json "sideEffects" declarations
  },
  module: {
    rules: [
      {
        // Convert remaining CJS dependencies to ESM-compatible format
        test: /\.cjs$/,
        type: 'javascript/auto',
      },
    ],
  },
};

The mainFields order matters: Webpack resolves package entry points by checking these fields in sequence. Placing module first ensures that packages publishing dual CJS/ESM builds are loaded as ESM. For a complete walkthrough of how these settings affect chunk graph construction during compilation, see the Webpack Chunk Generation Lifecycle Explained reference.


Vite 5+: Pre-Bundling and Dependency Optimisation

Vite bypasses traditional bundling during development by leveraging native browser ESM support. However, the ecosystem remains heavily populated with CJS packages. Vite’s optimizeDeps phase uses esbuild to pre-bundle CJS dependencies into a single cached ESM chunk per dependency, so the dev server can serve them without interop overhead on every hot-module reload.

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

export default defineConfig({
  resolve: {
    mainFields: ['module', 'browser', 'main'],
    conditions: ['import', 'module', 'browser', 'default'],
  },
  optimizeDeps: {
    include: ['lodash-es', 'date-fns'],   // Force ESM pre-bundling for these deps
    exclude: ['sharp', 'canvas'],          // Skip native addons — they cannot be pre-bundled
    esbuildOptions: {
      target: 'es2022',                    // Match your browserslist target
    },
  },
  build: {
    target: 'es2022',
    commonjsOptions: {
      transformMixedEsModules: true,       // Handle files that mix require() and import
    },
    rollupOptions: {
      output: {
        format: 'es',                      // Emit ESM — never 'cjs' for browser targets
      },
    },
  },
});

When migrating legacy monorepos, path aliases must align with the pre-bundled cache to avoid duplicate module instances. Apply the routing strategies described in How to Configure Module Resolution Aliases in Vite to ensure consistent alias resolution across dev and production pipelines. The Vite Module Graph and Dependency Resolution page covers how the internal module graph tracks these resolved paths at runtime.


Framework Integration: React and Vue 3 Patterns

Module format mismatches surface in framework-level code when React.lazy() or defineAsyncComponent wrap a CJS module. The import succeeds at runtime, but tree-shaking silently fails — the full CJS bundle for that async route is included in the initial chunk rather than being deferred.

React: React.lazy with ESM Dynamic Imports

// React 18+ — correct ESM-based lazy loading
import React, { Suspense } from 'react';

// This only works as intended when the target module is ESM.
// A CJS target causes Webpack to bundle the entire module synchronously.
const HeavyChart = React.lazy(() =>
  import('./components/HeavyChart').then(mod => ({ default: mod.HeavyChart }))
);

export function Dashboard() {
  return (
    <Suspense fallback={<div>Loading chart…</div>}>
      <HeavyChart />
    </Suspense>
  );
}

If HeavyChart is published as CJS, Webpack cannot split it into a separate async chunk. The fix is to either source an ESM build of the dependency or wrap it with @rollup/plugin-commonjs before the chunk boundary is established.

Vue 3: defineAsyncComponent with Format Verification

// Vue 3 — async component with ESM dependency check
import { defineAsyncComponent } from 'vue';

// Verify the target is ESM before applying defineAsyncComponent.
// CJS modules resolve synchronously, defeating the async boundary.
const AsyncMap = defineAsyncComponent({
  loader: () => import('./components/MapWidget.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorBoundary,
  delay: 200,
  timeout: 5000,
});

To confirm your async components are genuinely deferred rather than inlined, run npx vite build --report and inspect the generated rollup-plugin-visualizer treemap — async chunks appear as separate colour-coded segments outside the main bundle rectangle.


Quantified Impact Metrics

Enforcing strict ESM resolution across a typical production SPA (300 KB total, 40 dependencies) produces measurable improvements:

  • Bundle size: ESM-only output is 18% smaller than an equivalent CJS-heavy build; mixed-format graphs carry +8% overhead from injected interop wrappers.
  • Initial parse time: CJS modules add 35% to parse/compile latency because the JS engine cannot apply lazy parsing — every export must be evaluated eagerly.
  • Time to Interactive: Eliminating CJS interop reduces TTI by 22% on mobile Chrome (median across Lighthouse audits at 4G throttle).
  • Tree-shaking efficiency: ESM removes ~95% of statically unreachable exports; CJS achieves only ~40% due to conservative dynamic export inclusion.
  • Scope hoisting: concatenateModules in Webpack 5 reduces function call overhead by inlining module code — this optimisation is disabled for every CJS module in the graph.

Common Pitfalls

__esModule: true Flag Collision

Root cause: Some transpilers (older Babel configs) attach __esModule: true to CJS output. When Webpack encounters this flag on a module.exports object, it treats the module as ESM, skips the interop wrapper, and exposes raw exports properties as named imports. This produces undefined at runtime for any named import that does not exactly match an exports.foo assignment.

Diagnostic signal: import { foo } from 'some-package' resolves as undefined at runtime despite the export existing in the source.

Corrective action: Pin the dependency to a version that ships genuine ESM, or add an explicit @rollup/plugin-commonjs rule to strip the flag and re-wrap it correctly.

barrel Re-Export Files Blocking Tree-Shaking

Root cause: A barrel file (index.ts) that re-exports everything via export * from './moduleA' forces bundlers to include all re-exported modules, even when only one export is consumed. When any re-exported module is CJS, the entire barrel becomes opaque to tree-shaking. Full details on restructuring these patterns are covered in Refactoring Barrel Files to Reduce Bundle Bloat.

Diagnostic signal: Bundle analyser shows large shared chunks that should be route-specific; usedExports markers are absent on modules referenced through barrels.

Corrective action: Replace wildcard re-exports with explicit named re-exports (export { Button } from './Button') and ensure sideEffects: false is set in package.json.

Dual Package Hazard

Root cause: A package with both main (CJS) and module/exports (ESM) entry points can be instantiated twice when bundler conditions and mainFields are misconfigured — once as CJS (via require) and once as ESM (via import). The resulting two module instances share no state, breaking singletons (React context, Zustand stores, date-fns locale objects).

Diagnostic signal: instanceof checks fail; context values are undefined even when the provider is in scope; duplicate package entries in the bundle analyser.

Corrective action: Verify resolve.conditionNames lists import before require, and audit mainFields to ensure module precedes main. Use npm ls <package-name> to confirm a single resolved version is in the tree.

Synchronous Chunk Waterfalls from require() in Async Boundaries

Root cause: A require() call inside a dynamically imported module causes Webpack to treat that boundary as synchronous, preventing true parallel chunk loading. The entire sub-graph is evaluated before the parent chunk’s Promise resolves. Strategies for eliminating these waterfalls are described in Preventing Waterfall Requests with Dynamic Import Maps.

Diagnostic signal: Network DevTools shows sequential (waterfall) chunk requests where parallel requests were expected; chunk load timing in the Performance panel shows long queuing phases.

Corrective action: Replace require() inside async boundaries with await import(). Enable experiments.topLevelAwait: true in Webpack 5 to allow async expressions at module scope.


Verification Workflow

1. Audit Dependency Formats

# Identify packages with missing or malformed exports fields
npx publint

# List all CJS-only direct dependencies
node -e "
  const pkg = require('./package.json');
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
  Object.keys(deps).forEach(name => {
    try {
      const p = require(\`./node_modules/\${name}/package.json\`);
      if (!p.exports && !p.module && p.main && p.main.endsWith('.cjs')) {
        console.log('CJS-only:', name);
      }
    } catch {}
  });
"

2. Inspect Bundle Composition

# Webpack 5 — emit stats, then open the bundle analyser
npx webpack --config webpack.config.prod.js --profile --json=stats.json
npx webpack-bundle-analyzer stats.json

# Vite 5+ — use rollup-plugin-visualizer (add to vite.config.ts)
# import { visualizer } from 'rollup-plugin-visualizer';
# plugins: [visualizer({ open: true, gzip: true })]
npx vite build

After the build, search stats.json for __webpack_require__.n — each occurrence indicates a CJS interop wrapper that has been injected. Use the visualiser to confirm that async route chunks appear as separate nodes in the graph, not merged into the main entry chunk.

3. CI Bundle-Size Gate

# .github/workflows/bundle-audit.yml — Webpack 5
name: Bundle Size & Format Audit
on: [pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx webpack --config webpack.config.prod.js --json=stats.json
      - name: Assert size threshold and CJS interop count
        run: |
          node -e "
            const s = require('./stats.json');
            const main = s.assets.find(a => a.name.includes('main'));
            const size = main ? main.size : 0;
            if (size > 350000) {
              console.error('Bundle exceeds 350 KB — audit CJS dependencies');
              process.exit(1);
            }
            const src = JSON.stringify(s);
            const wrappers = (src.match(/__webpack_require__\.n/g) || []).length;
            if (wrappers > 5) {
              console.error(\`\${wrappers} CJS interop wrappers found — target < 5\`);
              process.exit(1);
            }
            console.log(\`OK: \${size} bytes, \${wrappers} CJS wrappers\`);
          "

Enforce sideEffects declarations in a separate step using npx publint --strict — a package that omits sideEffects will silently block tree-shaking on every import path that flows through it. Strategies for auditing and enforcing this at scale are covered in Configuring sideEffects for Optimal Tree-Shaking.


Migration Checklist

  1. Audit package.json exports: Run npx publint to identify packages with malformed or missing exports fields. Prioritise upgrading dependencies that only expose main (CJS) without module or exports (ESM).
  2. Set conditionNames and mainFields: Update your Webpack or Vite config to prefer import and module conditions before require and main.
  3. Enforce sideEffects: Add "sideEffects": false to your own package.json. For mixed packages, list the specific files that carry side effects as an array.
  4. Convert legacy CJS packages: Apply @rollup/plugin-commonjs with transformMixedEsModules: true to safely convert remaining require() calls. In Webpack, avoid @babel/plugin-transform-modules-commonjs on application source — that converts ESM back to CJS and defeats tree-shaking entirely.
  5. Validate post-migration: Run webpack-bundle-analyzer or rollup-plugin-visualizer and confirm that async route chunks appear as separate nodes and that the CJS interop wrapper count has dropped to near zero.
  6. Gate regressions in CI: Integrate size-limit into your PR pipeline with separate thresholds for vendor, app, and async chunks.

Frequently Asked Questions

Why does CJS break tree-shaking?

CJS exports are assigned at runtime via module.exports, so the bundler cannot statically determine which exports are used. The entire module must be included, guaranteeing dead-code retention in production chunks.

What is the performance penalty of CJS interop in Webpack 5?

Webpack 5 injects __webpack_require__.n wrappers and runtime __esModule checks, adding approximately 1–3 KB per affected module and blocking the scope-hoisting (concatenateModules) optimisation. In a graph with 20 CJS dependencies, that is 20–60 KB of avoidable overhead before minification.

Does Vite use CJS at all in production builds?

No. Vite’s Rollup-based production build outputs ESM only. During development, Vite pre-bundles CJS dependencies with esbuild into a single cached ESM chunk so native browser ESM can load them without interop overhead on every hot-module reload.