When ESM Imports CJS: How Module Mixing Breaks Tree-Shaking
In my previous post on bundle auditing, we dropped 18.7% from our SDK by switching lodash imports to direct paths. The treemap told the story: a quarter of the chart belonged to dead code nobody intended to ship. That fix changed what we imported. This post changes where we import it.
The same principle applies at a deeper level. You can write 100% ESM syntax, import only what you need, and still end up with a bloated bundle. The culprit sits in the module you imported from, not the code you wrote.
The Problem
When a file mixes CommonJS imports with ESM exports, bundlers like Vite and Rollup cannot perform proper static analysis. Importing only the ESM portion from a different file still causes the CJS dependency to land in the final bundle. Dead code the bundler cannot tree-shake out.
A minimal reproduction makes this concrete. Two patterns, same UI, same helper functions, same build toolchain. The only difference is module structure.
Mixed (CJS + ESM in one file): 178 kB Split (CJS and ESM separated): 137 kB
The mixed bundle is 30% larger. For the exact same application code.
The Setup
A monorepo with two packages. One app, one shared library:
cjs-esm-mix-up/
packages/
shared/
src/
helpers.ts ← pure ESM utilities
index-mixed.tsx ← exports helpers + DatePicker (CJS dependency)
index-split.ts ← exports helpers only
app/
src/
mixed-app.tsx ← imports from shared/mixed
split-app.tsx ← imports from shared/split
mixed.html
split.html
vite.config.ts
The shared package exports pure utility functions alongside a CJS component re-export. The helpers are trivial:
packages/shared/src/helpers.ts L1–22// packages/shared/src/helpers.ts
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
}
export function isWeekend(date: Date): boolean {
const day = date.getDay();
return day === 0 || day === 6;
}
export function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
export function getMonthName(date: Date): string {
return new Intl.DateTimeFormat('en-US', { month: 'long' }).format(date);
}
No side effects. No CJS. Just four functions that format dates.
The Mixed Pattern
packages/shared/src/index-mixed.tsx L1–41// packages/shared/src/index-mixed.tsx
import React from 'react';
import DayPicker from 'react-day-picker';
import { formatDate } from './helpers';
export { formatDate, isWeekend, addDays, getMonthName } from './helpers';
function formatSelectedDate(date: Date | undefined): string {
if (!date) return 'None';
return formatDate(date);
}
export function DatePicker({
selected,
onSelect,
}: {
selected?: Date;
onSelect?: (date: Date | undefined) => void;
}) {
return (
<div>
<DayPicker mode="single" selected={selected} onSelect={onSelect} />
<p style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
Selected: {formatSelectedDate(selected)}
</p>
</div>
);
}
This file exports pure ESM utilities and a DatePicker component that wraps react-day-picker. The component imports the CJS package directly. Both the utilities and the component live in the same module node.
The app imports only the helpers. It never uses DatePicker:
// packages/app/src/mixed-app.tsx
import { formatDate, isWeekend, addDays, getMonthName } from '@cjs-mixup/shared/mixed';
You might expect the bundler to tree-shake DatePicker and its react-day-picker dependency. It cannot.
178 kB — the mixed build treemap shows react-day-picker and all its submodules dominating the bundle:
Why the Bundler Cannot Drop It
react-day-picker v7.4.10 ships as CommonJS. The package.json declares "main": "build/index.js" with no "module" or "exports" field pointing at an ESM build:
The build output uses require() and module.exports throughout:
When a bundler encounters import { default as DayPicker } from 'react-day-picker', it resolves to this CJS file. The require() calls inside pull in every submodule: DayPicker, DateUtils, LocaleUtils, ModifiersUtils. The bundler cannot determine which of these are dead code because CJS requires execution to resolve exports.
Tree-shaking relies on static analysis of ES2015 module syntax. import and export declarations let the bundler trace the dependency graph at parse time, before any code runs. It knows what exists, what is used, and what can be pruned.
CommonJS breaks this. require() is a function call. The bundler cannot determine what it returns without executing the code:
// ESM — bundler sees this at parse time
import { formatDate } from './helpers';
export { formatDate };
// CJS — bundler must execute to resolve
const rdp = require('react-day-picker');
module.exports = { rdp };
The spec defines this distinction. Node’s ESM documentation states that when importing CommonJS modules, module.exports provides the default export, and named exports depend on static analysis that “does not always correctly detect named exports.” Rollup’s FAQ puts it more directly: ES modules “allow static analysis that helps with optimizations like tree-shaking.” CommonJS is “an idiosyncratic legacy format.”
The webpack docs define tree-shaking as relying on “the static structure of ES2015 module syntax, i.e. import and export.” When that structure points at a CJS package, the analysis stops.
The mixed file uses pure ESM syntax. No require(), no module.exports anywhere. You can write 100% ESM code and still hit this problem. The syntax does not matter. What matters is whether the dependency you imported is CJS under the hood.
When a single file contains both, the bundler must treat the entire module as potentially having side effects. It cannot safely prune the CJS portion because:
- CJS
require()can have arbitrary side effects — the module might modify globals, register plugins, or contain conditional logic - The bundler cannot see through CJS boundaries — it does not know what
require('react-day-picker')returns without bundling that module - Module-level side effects — even with
sideEffects: falsein package.json, bundlers remain conservative with mixed modules
This is the same pattern that bit the React ecosystem with @ant-design/icons. antd exported every icon through a barrel file. Libraries that imported a single icon shipped hundreds of SVG components. The issue reporter used source-map-explorer to prove the entire package appeared in the output. antd fixed it by recommending direct path imports.
The Split Pattern
Separate the CJS-dependent component into its own module boundary. The split entry exports only the pure utilities:
packages/shared/src/index-split.ts L1–12// packages/shared/src/index-split.ts
export { formatDate, isWeekend, addDays, getMonthName } from './helpers';
One line. No react-day-picker import. No DatePicker component. The CJS dependency does not appear anywhere in this file.
The app imports from the split entry:
// packages/app/src/split-app.tsx
import { formatDate, isWeekend, addDays, getMonthName } from '@cjs-mixup/shared/split';
The import source differs: shared/mixed versus shared/split. The mixed app pulls from the file that also defines DatePicker (which depends on react-day-picker). The split app pulls from the file that has no reference to it.
137 kB — the split build treemap has no trace of react-day-picker:
The Proof
Both apps do the same thing. Same UI, same logic, same components. A date display with navigation controls, month name, and weekend check. The code is identical except for the import source.
There is no behavioral difference. The output is pixel-identical. But the bundles are not.
Run the build yourself:
git clone https://github.com/JayaKrishnaNamburu/cjs-esm-mix.git
cd cjs-esm-mix
pnpm install
pnpm run build
Or see both side-by-side in dev:
pnpm run dev
# Mixed: http://localhost:3001
# Split: http://localhost:3002
One produces 178 kB. The other produces 137 kB. The extra 41 kB is dead code.
The Results
| Pattern | Modules | Size | gzip | react-day-picker in bundle? |
|---|---|---|---|---|
| Mixed (one file) | 27 | 178 kB | 55 kB | Yes — dead code |
| Split (separate files) | 25 | 137 kB | 46 kB | No |
The mixed bundle is 30% larger. The gzip difference (55 kB vs 46 kB) compounds across page loads. The extra 2 modules account for all of react-day-picker and its submodules, code the app never uses. The bundler pulled in DayPicker, DateUtils, LocaleUtils, ModifiersUtils because it could not prove they were dead code.
The Fix
Separate CJS and ESM into different module boundaries. Never import a CJS package in the same file that exports your ESM utilities.
| Approach | Tree-Shakeable? | Why |
|---|---|---|
| CJS + ESM mixed in one file | No | Bundler cannot prove CJS side effects are dead |
| Split into separate files | Yes | Each module has a clean dependency graph |
Dynamic import() | Yes (lazy) | Defers CJS evaluation, adds async overhead |
When module boundaries enforce the separation, the bundler traces clean graphs. CJS components live in their own entry point. ESM utilities live in theirs. No auditing required.
Real-World Impact
It affects:
- Published npm packages that are CJS-only and get re-exported alongside ESM code
- Monorepo shared packages that aggregate utilities and CJS component libraries
- Any file that imports a CJS package and also exports unrelated ESM helpers
In large applications, a single co-located CJS import in a shared utilities file can pull in tens or hundreds of kilobytes of dead code. The browser parses, compiles, and executes that code. On low-end phones, the main-thread blocking time delays interactivity. Across millions of requests, the bandwidth compounds.
The reproduction demonstrates the problem: 178 kB versus 137 kB for identical functionality. Real codebases land somewhere in between. A shared utils file that re-exports a CJS date library alongside formatting helpers will ship the date library to every page that imports a formatter. The treemap will show it. The fix is the same: separate the boundaries.
Two CJS Problems, Two Fixes
This post and the previous one on bundle auditing both deal with CJS dead code. The difference is where the contamination lives.
The lodash problem was direct: import { debounce } from 'lodash' pulls from a CJS barrel file. The package itself ships as CJS. The fix changed the import path to target individual modules (import debounce from 'lodash/debounce').
The mixed import problem is indirect: your code is 100% ESM. The import source is where the CJS lives. A shared utilities file that re-exports helpers alongside a CJS component forces the bundler to keep the entire module graph. The fix separates the file boundaries.
Both produce the same outcome: dead code in the bundle. But the first is a package-level issue you fix with import paths. The second is an architecture-level issue you fix with module boundaries.
Related Links
- Node.js ESM Documentation - The spec behavior for ECMAScript modules and CJS interop in Node.js
- Rollup FAQ: ES Modules vs CommonJS - Why ESM enables tree-shaking and CJS does not
- Webpack Tree Shaking Guide - How webpack performs dead code elimination with ES module syntax