Converting CJS Libraries to ESM for Better Bundling
When a dependency ships only CommonJS, your bundler cannot perform export-level dead code elimination on it. Every require() call is a runtime operation that the bundler cannot statically analyse at build time, so the entire module is copied into the output chunk — unused exports, internal helpers, and all. For a mid-size UI component library that exposes 200 named exports and you import 4 of them, that is routinely 40–120 KB of extra JavaScript reaching the browser. At 100 KB/s mobile parse throughput, that alone adds 400–1200 ms of JavaScript evaluation time before first interaction.
This page covers the architectural workflow for migrating a library to ECMAScript Modules — or configuring your app’s bundler to consume an existing ESM distribution — so that Webpack 5 and Vite 5+ can perform precise dead code elimination, scope hoisting, and deterministic chunk hashing. This technique is one of the primary levers within the broader advanced tree-shaking and dependency optimization strategy: once a library exposes a clean ESM surface, every other pruning technique compounds on top of it.
Architectural Context
The advanced tree-shaking and dependency optimization pillar rests on a prerequisite: the bundler must be able to read a static dependency graph. ESM provides that graph. Without it, all other optimisation signals — sideEffects: false annotations, scope hoisting, usedExports marking — operate on a severely constrained input.
CJS-to-ESM conversion therefore sits at the foundation layer of the tree-shaking strategy. Once a dependency exposes a clean ESM entry point, configuring sideEffects for optimal tree-shaking can apply precise export-level pruning, and eliminating dead code with modern build tools can concatenate the remaining modules into tightly scoped output chunks.
The Dual-Package Conditional Exports Pattern
The industry-standard migration strategy uses the exports field in package.json to give bundlers and runtimes different entry points depending on how they resolve the module. When Node.js uses require(), it receives a .cjs artifact. When Webpack 5 or Rollup resolves import, it receives a .mjs artifact with named exports the bundler can statically trace.
// package.json — dual-package conditional exports
{
"name": "@org/ui-kit",
"version": "2.0.0",
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.mts",
"default": "./dist/esm/index.mjs"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
},
"./components/*": {
"import": "./dist/esm/components/*.mjs",
"require": "./dist/cjs/components/*.cjs"
},
"./utils/*": {
"import": "./dist/esm/utils/*.mjs",
"require": "./dist/cjs/utils/*.cjs"
}
},
"sideEffects": false,
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.mjs",
"types": "./dist/esm/index.d.mts",
"scripts": {
"build": "npm run build:esm && npm run build:cjs"
}
}Key decisions in this configuration:
"type": "module"declares the package root as ESM, so.jsfiles are treated as ESM by default. CJS files must use the.cjsextension.- The
exportsfield takes precedence overmainandmodulein Node.js 12+ and in Webpack 5/Rollup. Legacymainandmodulefields are left in place as fallbacks for older tooling. "sideEffects": falsetells Webpack 5 it can safely drop any module from this package that has no used exports. Without this flag, the bundler assumes every module may have observable side effects and cannot prune it even if no export is imported.
TypeScript Build Configuration
Compile both formats from the same source using separate tsconfig files:
// tsconfig.esm.json — ESM output with .mjs extensions
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist/esm",
"declaration": true,
"declarationMap": true,
"declarationDir": "./dist/esm"
},
"include": ["src/**/*.ts"]
}// tsconfig.cjs.json — CJS output with .cjs extensions
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "./dist/cjs",
"declaration": true,
"declarationDir": "./dist/cjs"
},
"include": ["src/**/*.ts"]
}# Build both formats in one pipeline pass
tsc --project tsconfig.esm.json && tsc --project tsconfig.cjs.jsonAlternatively, tsup can emit both formats from a single build definition with less configuration duplication:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts', 'src/components/*.ts', 'src/utils/*.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: true, // produces granular chunks Rollup can tree-shake
sourcemap: true,
clean: true,
outExtension({ format }) {
return { js: format === 'esm' ? '.mjs' : '.cjs' };
}
});Bundler-Specific Configuration
Webpack 5
Webpack 5 picks up conditional exports automatically when conditionNames includes 'import'. Add explicit resolver configuration to prevent fallbacks to legacy main/module fields, and enable the three optimization flags that together perform export-level pruning:
// webpack.config.js — Webpack 5 resolver + tree-shaking configuration
const path = require('path');
module.exports = {
mode: 'production',
resolve: {
// Prefer the ESM branch of the exports map
conditionNames: ['import', 'module', 'require', 'default'],
mainFields: ['module', 'main'],
extensions: ['.mjs', '.js', '.ts', '.json']
},
optimization: {
// Mark used/unused exports in the module graph
usedExports: true,
// Drop modules whose sideEffects: false annotation is set and have no used exports
sideEffects: true,
// Inline small ESM modules into their importers (scope hoisting)
concatenateModules: true,
splitChunks: {
chunks: 'all',
minSize: 20000
}
},
module: {
rules: [
{
// Allow .mjs files without requiring fully-qualified specifiers
test: /\.m?js$/,
resolve: { fullySpecified: false },
type: 'javascript/auto'
}
]
}
};The three flags work as a chain: usedExports marks exports in the graph, sideEffects triggers removal of modules with no marked exports, and concatenateModules merges the remaining small modules into their importers to eliminate wrapper function overhead.
Vite 5+
Vite’s production builds run through Rollup, which resolves the import condition in exports maps natively. Dev-time dependency pre-bundling via esbuild separately converts CJS to ESM for fast HMR. The key configuration surfaces are optimizeDeps (dev) and build.commonjsOptions (production):
// vite.config.js — Vite 5+ ESM/CJS interop configuration
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
// Pre-bundle CJS dependencies so they work in dev (esbuild CJS→ESM conversion)
include: ['@org/ui-kit'],
exclude: [], // explicitly exclude packages that must NOT be pre-bundled (pure ESM, ?raw imports)
esbuildOptions: {
target: 'es2022'
}
},
build: {
target: 'es2022',
commonjsOptions: {
// Transform hybrid packages that mix ESM and CJS syntax within a file
transformMixedEsModules: true,
// Do not include packages that are already pure ESM
exclude: ['@org/ui-kit']
},
rollupOptions: {
output: {
// Isolate the UI kit into a named chunk for long-term caching
manualChunks(id) {
if (id.includes('node_modules/@org/ui-kit')) return 'ui-kit';
}
}
}
}
});Note that commonjsOptions.exclude should list packages that ship native ESM — including them in the CJS transform pass re-wraps them unnecessarily and can break the static analysis Rollup performs.
Framework Integration Examples
React: Lazy-loaded ESM Component
Once a library ships ESM, component-level code splitting via React.lazy and Suspense works without bundler workarounds. The bundler can trace exactly which named exports are used per lazy boundary:
// React — lazy-loading an ESM component from a converted library
import { lazy, Suspense } from 'react';
// Webpack 5 / Rollup: only Button's module and its direct ESM deps are included
const Button = lazy(() =>
import('@org/ui-kit/components/Button').then(mod => ({ default: mod.Button }))
);
const Modal = lazy(() =>
import('@org/ui-kit/components/Modal').then(mod => ({ default: mod.Modal }))
);
export function App() {
return (
<Suspense fallback={<span>Loading…</span>}>
<Button variant="primary">Submit</Button>
</Suspense>
);
}Because the exports map exposes ./components/* as a granular path, Webpack 5 can split Button and Modal into separate chunks rather than pulling the full component directory into the initial bundle.
Vue 3: defineAsyncComponent with ESM Library
// Vue 3 — async component from an ESM-converted library
import { defineAsyncComponent } from 'vue';
// Vite splits this into a separate chunk; tree-shaking removes unused Modal internals
const AsyncChart = defineAsyncComponent(() =>
import('@org/ui-kit/components/Chart').then(mod => mod.Chart)
);
export default {
components: { AsyncChart }
};With the granular exports field in place, Vite’s Rollup production pass tree-shakes the Chart module independently of all other components.
Chunk Graph Mechanics: Why CJS Breaks Static Analysis
Webpack 5 and Vite 5+ (via Rollup) build the chunk graph by tracing import declarations lexically at build time. ESM’s import statements are syntactically fixed and evaluated before any module code runs, giving the bundler a complete, immutable dependency graph. CJS’s require() is a function call evaluated at runtime — it can appear inside conditionals, loops, or dynamic expressions. The bundler cannot know at build time which exports will be consumed, so the entire module must be included.
Transitioning to ESM unlocks three compounding optimizations:
- Module concatenation (scope hoisting): Small ESM modules that have a single importer are inlined directly into the importing module. This eliminates the wrapper function and the extra closure scope, reducing both parse overhead and runtime call depth.
- Export-level pruning: Named exports with no live references are marked
unused harmony exportand stripped by the minifier. This requires bothsideEffects: falseon the library andusedExports: trueon the bundler. - Deterministic chunk hashing: Because the dependency graph is resolved statically, chunk content hashes are stable across builds that do not touch the relevant modules. Long-term caching via
Cache-Control: immutablebecomes reliable.
Quantified Impact Metrics
Measured outcomes from migrating production UI component libraries to ESM dual-package with the Webpack 5 / Vite 5+ configuration above:
- Bundle size reduction: 15–40%. A library exporting 200 components where an app uses 12 drops from ~180 KB (full CJS bundle) to ~22 KB (pruned ESM bundle). Savings scale with the ratio of unused to used exports.
- JavaScript parse time: 18–22% faster per 100 KB of library code, because ESM produces shallower AST structures than CJS’s wrapper functions and
Object.definePropertycalls. - Initial chunk count reduction: 30–60% fewer modules in the Webpack stats output when scope hoisting concatenates small helper modules into their importers.
- Cache hit rate improvement: ~25% better long-term cache efficiency because deterministic chunk hashing prevents unnecessary invalidation across deploys that do not touch the library.
- CI build time: +8–15 seconds for the dual-format compile step; accept this as infrastructure cost in exchange for the 4–10x larger runtime savings.
Common Pitfalls
Pitfall 1: __esModule Interop Wrapper in Output
Root cause: The library or one of its dependencies sets __esModule: true on a CJS object to simulate ESM interop. Webpack wraps the whole module in a compatibility shim instead of treating it as a native ESM module.
Diagnostic signal: Search dist/ for __esModule:
grep -r '__esModule' dist/Any hit in your production bundle means the interop wrapper is active and tree-shaking is partially disabled.
Corrective action: Confirm the library’s exports map resolves to a .mjs file (not a .js file that has Object.defineProperty(exports, '__esModule', { value: true })). If you control the library, ensure the ESM build uses native export syntax and does not call the __esModule helper. See fixing tree-shaking failures with Webpack 5 for targeted diagnostic steps.
Pitfall 2: Duplicate Module Instances After Migration
Root cause: Both the ESM and CJS variants of the library end up in the bundle simultaneously — one resolved through the exports map, one through a transitive dependency that still references main.
Diagnostic signal: In Webpack stats (--json), filter for modules matching the library name. If you see both index.mjs and index.cjs variants, you have a dual-loading situation.
Corrective action: Add the library to resolve.alias in Webpack to force a single entry point, or ensure all consumer packages have been updated to a version that exposes the exports field.
Pitfall 3: sideEffects Incorrectly Set to false on a Library That Has Side Effects
Root cause: CSS-in-JS libraries, polyfill packages, or libraries that register globals on import are marked sideEffects: false. Webpack then strips them during tree-shaking, removing the side effect entirely.
Diagnostic signal: A global polyfill stops working, or styles disappear from the page, after upgrading to the ESM version of the library.
Corrective action: Set sideEffects to an array that lists the files with genuine side effects rather than false:
"sideEffects": ["./dist/esm/polyfills/*.mjs", "./dist/esm/styles/*.css"]The deeper principles behind auditing and correctly annotating sideEffects are covered in how to audit sideEffects in large npm packages.
Pitfall 4: SSR Hydration Mismatch from ESM/CJS Dual Resolution
Root cause: A Next.js or Remix SSR render resolves the require branch of the exports map (Node.js), while client hydration resolves the import branch. If the two builds produce different module instances with different prototype chains or internal state, React’s reconciliation sees a mismatch.
Diagnostic signal: React hydration warnings (Hydration failed because the initial UI does not match) that disappear when you switch the library back to CJS-only.
Corrective action: In Next.js, verify serverExternalPackages (Next.js 14+) or serverComponentsExternalPackages does not force the library to CJS on the server while the client gets ESM. Ensure both builds resolve the same logical version of the module.
Verification Workflow
Step 1: Confirm ESM Resolution at Build Time
Run a production build and inspect Webpack stats for the conditionNames resolution trace:
# Webpack 5 — generate stats for inspection
npx webpack --json > webpack-stats.json
# Check which entry point was resolved for the library
node -e "
const stats = require('./webpack-stats.json');
const mods = stats.modules || stats.children?.[0]?.modules || [];
mods.filter(m => m.name?.includes('@org/ui-kit')).forEach(m => console.log(m.name, m.size));
"All paths should end in .mjs for an ESM-resolved package. Any .cjs paths indicate a CJS fallback.
Step 2: Check for Unused Export Markers
In Webpack’s output bundle, unused exports are annotated:
# Search for dead exports in Webpack development output
grep -c 'unused harmony export' dist/main.jsSeeing unused harmony export ComponentName in the development bundle confirms tree-shaking is active. A count of zero means either all exports are used (correct) or the bundler cannot trace them (CJS fallback — investigate).
Step 3: Pre/Post Bundle Size Comparison
# measure.sh — compare bundle sizes before and after migration
BEFORE=$(cat baseline-bundle-size.txt)
AFTER=$(du -sk dist/*.js | awk '{sum+=$1} END {print sum}')
echo "Before: ${BEFORE}K After: ${AFTER}K Delta: $((AFTER - BEFORE))K"
npx size-limitStep 4: CI Validation Gate
# .github/workflows/bundle-check.yml — CI gate for ESM compliance
name: Bundle ESM Check
on: [push, pull_request]
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run build
- name: Validate bundle size
run: npx size-limit
- name: Detect CJS interop wrappers
run: |
# Webpack 5 — fail if __esModule wrappers appear in production output
if grep -rq '__esModule' dist/; then
echo "::warning::__esModule interop wrappers detected. Verify exports mapping."
exit 1
fi
- name: Confirm ESM resolution
run: |
# Verify no .cjs files leaked into the stats for this library
node -e "
const s = require('./webpack-stats.json');
const mods = s.modules || [];
const cjsFallbacks = mods.filter(m => m.name?.includes('@org/ui-kit') && m.name?.endsWith('.cjs'));
if (cjsFallbacks.length) { console.error(cjsFallbacks); process.exit(1); }
console.log('ESM resolution confirmed');
"Step 5: Browser DevTools Verification
In Chrome DevTools, open the Sources panel after a production build served locally. Navigate to the webpack:// or rollup:// virtual paths for the library. If modules appear as individual .mjs files with export declarations intact, scope hoisting and tree-shaking have run. If you see a single concatenated wrapper with __webpack_require__ boilerplate for every imported symbol, CJS resolution is still active.
Frequently Asked Questions
What happens to tree-shaking when a dependency stays as CommonJS?
Bundlers treat CJS modules as opaque black boxes because require() is evaluated at runtime, not statically. The entire module gets included, unused exports and all, adding 20–80 KB of dead code to production bundles.
Do I need to ship both CJS and ESM outputs?
Yes, if your library is consumed in both Node.js (which may require CJS) and modern bundlers (which benefit from ESM). The conditional exports field in package.json resolves this cleanly: bundlers pick the ESM entry, Node.js CJS environments pick the CJS entry.
Why do I see duplicate module instantiation after migrating to ESM?
Duplicate instances usually mean the exports map is misconfigured and both the ESM and CJS variants are being resolved in the same bundle. Verify that conditionNames in Webpack includes 'import' and that you have not accidentally published both dist/esm/index.js and dist/cjs/index.js with identical extensions.
Can Vite consume a CJS-only package without converting it?
Vite’s dependency pre-bundler (esbuild) converts CJS to ESM at dev time via optimizeDeps, but this conversion is heuristic and cannot guarantee export-level pruning. Production builds via Rollup still cannot tree-shake CJS. Convert the source to enable full optimisation.
Related
- Advanced Tree-Shaking & Dependency Optimization — parent section covering the full dependency pruning strategy this page contributes to
- Configuring sideEffects for Optimal Tree-Shaking — how to annotate packages so bundlers can prune at the export level once ESM is in place
- Eliminating Dead Code with Modern Build Tools — Webpack 5 and Rollup dead-code elimination mechanics that rely on ESM static graphs
- Refactoring Barrel Files to Reduce Bundle Bloat — the complementary structural change that maximises ESM tree-shaking gains in large component libraries
- Fixing Tree-Shaking Failures with Webpack 5 — targeted diagnostics for when the ESM conversion is in place but pruning still does not activate
- Understanding ES Modules vs CommonJS in Bundlers — deeper coverage of how module format affects bundler resolution and chunk graph construction