Tree Shaking, Code Splitting and Bundle Size
Why JavaScript bytes cost more than image bytes, how bundlers drop unused exports, what quietly stops them, and how splitting and budgets keep bundles small.
A bundler starts from your entry files, follows every import, and writes out the code it reached. Tree shaking is the step that leaves out exports nothing imports, which only works because ES module imports are static and can be read without running the code. It silently stops working when a module might have side effects, when code is CommonJS, or when a barrel file pulls a whole library into the graph. Code splitting handles the rest: code that is used, just not on this page, goes into separate chunks loaded on demand.
Context
Early JavaScript bundlers (Browserify in 2011, webpack 1 in 2014) bundled CommonJS, where require() is an ordinary function call that can sit inside an if and take a computed path. A tool cannot know which parts of such a module are used without executing it, so it includes all of it. ES modules, or ESM (ES2015), made imports and exports static declarations, and Rollup (2015, by Rich Harris) used that to drop unreferenced exports, popularising the term "tree shaking". webpack added it in version 2 (2017) and the sideEffects package flag in version 4 (2018); Vite, esbuild, Turbopack and Rspack all do it today.
You have met it as the moment a one-line import of a date or icon library added hundreds of kilobytes to a build, as the "First Load JS" column that next build prints for every route, and as Lighthouse's "Reduce unused JavaScript" warning. The whole idea in two files:
export function add(a, b) { return a + b }
export function heavyStats(data) { /* 40 KB of code */ }
// app.js
import {add} from './math.js'
console.log(add(1, 2))
// production bundle: add() is kept, heavyStats() is never emitted- Bundle / chunk
- A file the bundler emits. One app usually produces several chunks: a main one, one per route, and shared vendor code.
- Module graph
- Every module the bundler reached from the entry points and which exports each one imports from the others.
- Side effect
- Code that does something just by being imported: patching a global, registering a plugin, importing CSS. It must be kept even if no export is used.
- Barrel file
- An index.ts that re-exports a folder or library (export * from …) so callers can import everything from one path.
- Code splitting
- Emitting separate chunks that load on demand through dynamic import(), so a page downloads only the code it needs.
Why it matters
A kilobyte of JavaScript costs far more than a kilobyte of image. Both have to be downloaded, but script then has to be parsed, compiled and executed on the main thread, and a mid-range phone does that several times slower than a developer laptop. While it runs, the page cannot respond to taps, which shows up as poor INP (Interaction to Next Paint) and a late-interactive page after server rendering. Bundles also grow one dependency at a time and nobody notices any single step, so the only teams with small bundles are the ones that measure them on every change.
How tree shaking decides what to drop
The bundler builds the module graph from the entry points, marks every export that some module imports, and leaves unmarked exports out (or lets the minifier delete them as unreachable). Whole modules can be skipped only if the bundler is sure that importing them has no effect. Without that guarantee it must keep the module's top-level code, because removing it could change behaviour, for example a polyfill or a window.analytics = … assignment.
The sideEffects field in a package's package.json is how a library makes that promise. With false, the bundler may skip any of its modules whose exports go unused. With a list, everything not on the list is considered pure. Getting it wrong in the other direction is a real bug: marking a CSS import or a polyfill as pure makes it vanish from production builds.
{
"name": "@acme/ui",
"type": "module",
"module": "./dist/index.js",
"sideEffects": ["*.css", "./dist/polyfills.js"]
}Inside a module, the minifier (Terser, esbuild or SWC) removes code that is provably unused. A top-level call such as const theme = createTheme() is kept even if theme is never read, because the call might do something. Library authors mark such calls with a /*#__PURE__*/ comment to say "safe to delete if the result is unused"; that is how compiled React components and styled-component factories stay shakeable.
What quietly breaks it
| Cause | Why the bundler keeps the code | Fix |
|---|---|---|
| CommonJS | require() and module.exports are runtime values, so usage cannot be analysed statically | Use the package’s ESM build (lodash-es), or a library that ships ESM |
| Barrel files | Importing one name loads the whole index, and any module in it with possible side effects stays | Import from the specific path, or let the framework rewrite barrel imports |
| Top-level effects | Code that runs on import might matter, so the module is kept | Move work into functions, add sideEffects, annotate pure calls |
| Dynamic access | import * as lib then lib[name] may touch any export | Import the names you use explicitly |
| Transpiled to CJS | Babel or TypeScript set to emit CommonJS turns your own ESM into require() | Keep "module": "esnext" or "preserve" and let the bundler handle modules |
// pulls in the whole icons index: 1,500 modules
// to evaluate, and any with side effects stay
import {SearchIcon} from '@acme/icons'// one module, nothing else enters the graph
import SearchIcon from '@acme/icons/dist/search'
// or keep the barrel and let Next.js rewrite it
// next.config.ts: optimizePackageImports: ['@acme/icons']Code splitting: ship what this page needs
Tree shaking removes code nobody uses. Most of a large app is used, just not on every page: the editor, the charting library, the admin screens. A dynamic import() tells the bundler to put a module and its dependencies in a separate chunk that loads only when that line runs. Frameworks split by route automatically; component-level splitting is for heavy widgets that appear behind an interaction.
'use client'
import dynamic from 'next/dynamic'
// chart library (~200 KB) loads only when the panel renders
const Chart = dynamic(() => import('./Chart'), {
loading: () => <ChartSkeleton />,
ssr: false,
})
// plain React: const Chart = lazy(() => import('./Chart'))
export function ReportPanel({open}: {open: boolean}) {
return open ? <Chart /> : null
}
// warm it up on intent so the click feels instant
export const preloadChart = () => import('./Chart')Splitting trades one large download for several smaller ones, and each costs a request at the moment it is needed. Load likely-next chunks on intent (hover, focus, visible link) rather than on click, which is what Next.js <Link> prefetching does for routes. Splitting too finely produces dozens of tiny chunks whose overhead outweighs the savings.
Measure it and keep it small
Bundle size only stays down if a number is checked on every change. The workflow below finds the big wins once and then prevents regressions.
- 1Read the build output:
next buildprints First Load JS per route, and Vite shows chunk sizes. Note the routes that load the most. - 2Open a treemap of the heaviest chunk with webpack-bundle-analyzer,
@next/bundle-analyzer, rollup-plugin-visualizer or source-map-explorer, and look for whole libraries where you expected one function. - 3In Chrome DevTools, the Coverage panel shows how much of each loaded script ran during a page load; a chunk that is 70% unused on the first route is a splitting candidate.
- 4Replace or narrow the worst dependencies: per-function imports from lodash-es instead of lodash, date-fns or the built-in
IntlAPIs instead of Moment.js (in maintenance mode since 2020), nativefetchinstead of a request library. - 5Set a budget in CI (continuous integration) with a tool such as size-limit or your framework's bundle stats, so a pull request that adds 50 KB fails or at least shows the delta in review.
Ship less to modern browsers
Your browserslist targets decide how much syntax is down-compiled and how many polyfills are added. Supporting browsers your users no longer run makes every visitor download code for them. Compression with gzip or Brotli (RFC 7932, 2016) shrinks transfer size, typically by 60-80% for JavaScript, but the browser still parses and executes the full uncompressed code, so it is not a substitute for shipping less.
Pitfalls
- Importing a whole library for one function
import _ from 'lodash'is CommonJS and comes in whole, around 70 KB minified. The same function from lodash-es or a per-method import is a few hundred bytes. Check the import cost of anything new before it lands, because removing a dependency later means touching every caller. - Marking a package side-effect free when it is not
"sideEffects": falselets the bundler drop any module whose exports are unused, including one that only imports CSS or registers a polyfill. Styles or behaviour then disappear in production but not in development, which is hard to trace. List those files explicitly. - Compiling your own code to CommonJS
A tsconfig or Babel preset that emits CommonJS converts your ES modules before the bundler sees them, so nothing in your own code can be shaken. Let the bundler own module transformation: keep ESM output and set the module resolution your bundler expects.
- Splitting without prefetching
A chunk that starts downloading on click adds a network round trip to the interaction, so the button feels slower than before the split. Preload on hover or when the trigger scrolls into view, and keep the skeleton in place while it arrives.
- Measuring gzip size only
Transfer size flatters JavaScript, because it compresses well. Main-thread cost follows the uncompressed size, so track both and set budgets on the parsed size too.
Interview questions
Q1What is tree shaking and why does it need ES modules?
Tree shaking is dead-code elimination at module level: the bundler keeps only the exports something actually imports. It needs ES modules because their imports and exports are static declarations the bundler can read without running anything, while CommonJS require() is a runtime call that can be conditional or computed, so its usage cannot be proven.
Q2What does the sideEffects field in package.json do?
It tells the bundler which of the package's modules do something just by being imported. Modules not listed (or all of them, with false) can be skipped entirely when none of their exports are used, which lets whole files fall out of the bundle instead of only unused functions. Files like CSS imports and polyfills must stay listed or they disappear from production builds.
Q3Why are barrel files a problem?
Importing one name from an index that re-exports hundreds of modules makes the bundler, dev server and test runner load all of them. If any of those modules may have side effects, they stay in the production bundle too. Importing from the specific path, or using a tool such as Next.js optimizePackageImports that rewrites barrel imports, avoids it.
Q4Walk me through reducing the JavaScript on a slow route.
First measure: build output for the route's first-load size, a treemap of its chunks, and the Coverage panel for unused code. Then cut in order of size: replace or narrow heavy dependencies, fix imports that defeat tree shaking, move widgets that appear behind interactions into dynamic imports with prefetch on intent, and move server-only rendering out of the client bundle. Finally add a size budget in CI so the route cannot regrow unnoticed.
Q5What happens when you dynamically import a component?
The bundler emits the component and everything only it depends on as a separate chunk, and replaces the import with code that fetches that chunk when the line runs. The first render waits for the network, so you show a fallback through Suspense or a loading component, and you preload the chunk on hover or visibility so the delay is mostly hidden.
Q6Gzip makes our bundle 300 KB instead of 1 MB. Is that fine?
Only for download time. The browser decompresses and then parses, compiles and executes the full megabyte on the main thread, and that is what delays interactivity on mid-range phones. Budget and optimise the uncompressed size; compression is a transport optimisation, not a reduction of work.
Q7Why might code not be removed even though nothing uses it?
Because the bundler cannot prove that removing it is safe. Top-level calls may have side effects, CommonJS usage cannot be analysed, namespace imports with computed property access might use any export, and a package without a sideEffects declaration is assumed to have them. Pure annotations, explicit named imports and ESM builds give it the proof it needs.
- JavaScript costs download plus parse, compile and execute on the main thread, so bundle size shows up as slow interactivity, not just slow loading.
- Tree shaking keeps only imported exports and depends on static ES module syntax; CommonJS defeats it.
- Code that might have side effects is kept.
sideEffectsin package.json and/*#__PURE__*/annotations are how code proves it is safe to drop. - Barrel files and namespace imports pull far more into the graph than the one name you used; import specific paths.
- Split by route and behind interactions with dynamic
import(), and prefetch on intent so the split is invisible. - Measure with build output, a treemap and Coverage, then hold the line with a size budget in CI.