Slimming Down Your Bundle: Tree-Shaking, Code Splitting, and Bundle Analysis
Measure first, optimize second. We'll look at why tree-shaking sometimes silently fails, where code splitting actually pays off, and how to use analysis tools to find the dependencies that are really weighing you down.
Why Is a Big Bundle a Problem?
Once the browser gets your JavaScript, it has to do three things: download it, parse and compile it, and execute it. The bigger the file, the slower all three get. Worse, the same file can take several times longer to run on a budget phone than on a flagship. So bundle size isn't just about "a slightly slower download." It directly drags down user-facing metrics like LCP and INP.
When a bundle gets too big, a lot of people's first instinct is to swap in a smaller package or crank up the compression. But before you touch anything, let's agree on one principle: measure first, then optimize. Slim things down without measuring and you can easily burn a whole day to save 3 KB.
This article breaks bundle optimization into three steps that together make a complete workflow:
- Bundle analysis: see what's actually inside your bundle.
- Tree-shaking: delete the code you never use.
- Code splitting: defer the code you don't need yet.
Step 1: See What's Inside
If you wanted to lose weight, the first thing you'd do is step on a scale, not start dieting. Bundle analysis is the scale for frontend code.
Some common tools:
- webpack-bundle-analyzer: shows how much space each module takes up as a treemap. The go-to in the webpack ecosystem.
- rollup-plugin-visualizer: for Vite and Rollup projects. Outputs a treemap or sunburst.
- @next/bundle-analyzer: a wrapper for Next.js projects, built on webpack-bundle-analyzer under the hood.
- source-map-explorer: bundler-agnostic. It analyzes your source maps directly.
Here's what the setup looks like with Vite:
// vite.config.js
import { defineConfig } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
visualizer({
filename: 'stats.html', // output the analysis report
gzipSize: true, // show gzipped size
open: true,
}),
],
})Once you open the report, you'll usually run into a few classic situations:
- One giant block: a single dependency takes up a third of the bundle, like a full copy of moment.js with every locale file.
- The same package showing up multiple times: different versions of a dependency got bundled separately.
- Things you didn't expect: a charting library that's only used in the admin panel somehow ended up in the homepage bundle.
One tip for reading the report: tell stat size (the raw source size), parsed size (the actual size before compression), and gzip size (the transfer size) apart. Downloads depend on gzip size, while parsing and execution depend on parsed size. They mean different things, so don't compare them against each other.
Step 2: Tree-Shaking, Delete What You Don't Use
What Is Tree-Shaking?
The name is a good picture of what it does. Think of your code as a tree and give it a good shake: the dead leaves nobody references fall off. The bundler analyzes how modules reference each other and removes any export that nothing uses from the final output. This process is also known as dead code elimination.
// math.js
export function add(a, b) {
return a + b
}
export function multiply(a, b) {
return a * b
}
// main.js
import { add } from './math.js'
console.log(add(1, 2))
// nobody uses multiply, so the final bundle won't include itWhy Does It Have to Be ES Modules?
Tree-shaking has a prerequisite: the dependencies between modules must be known at compile time. ES Modules' import and export are static syntax. They have to sit at the top level, and the path has to be a string literal, so a bundler can tell who uses what without running any code.
CommonJS require, on the other hand, is dynamic:
// CommonJS: dependencies aren't known until runtime
const name = condition ? 'a' : 'b'
const mod = require('./' + name)Faced with code like this, a bundler can only play it safe and keep the whole module. That's why you so often see "I only used one lodash function, but the entire lodash library came along."
Why Does Tree-Shaking Sometimes Fail?
Tree-shaking is actually conservative: if the bundler isn't sure that removing something is safe, it leaves it in. Here are the usual culprits.
1. The module has side effects
If a module does something the moment it's imported, like modifying a global or registering a polyfill, the bundler can't just delete it, even if you never use any of its exports.
// this module modifies a global as soon as it's loaded, so it can't be safely removed
Array.prototype.last = function () {
return this[this.length - 1]
}
export function helper() {}In this case, you can use the sideEffects field in package.json to tell the bundler: "this package is clean, so feel free to drop any files that aren't used."
{
"name": "my-lib",
"sideEffects": false
}If only some files have side effects, switch to an array and list them explicitly, for example "sideEffects": ["*.css", "./src/polyfill.js"]. Be careful here: importing a CSS file is itself a side effect. Mark CSS files as side-effect-free by mistake and your styles will vanish into thin air.
2. The package only ships CommonJS
As we saw earlier, CommonJS is hard to analyze statically. When you're picking dependencies, check whether the package's package.json has a module or exports field that points to an ESM build.
3. Babel turned your ESM into CommonJS
This is a really common trap. If @babel/preset-env is set to modules: 'commonjs', your ESM gets converted to require calls before the bundler ever sees it, and tree-shaking has nothing left to work with. Let the bundler handle modules, and keep ESM intact:
// babel.config.js
module.exports = {
presets: [['@babel/preset-env', { modules: false }]],
}4. Importing in a tree-shaking-unfriendly way
// pulls in the whole object, so the bundler can't tell which parts you actually use
import _ from 'lodash'
_.debounce(fn, 300)
// better: import only what you need
import debounce from 'lodash/debounce'
// even better: switch to the ESM build, lodash-es
import { debounce } from 'lodash-es'Also, barrel files (an index.js that re-exports a bunch of modules) can hurt tree-shaking and build speed in some situations, especially when the re-exported modules have side effects themselves. If your project has any large barrels, they're worth another look.
Tree-Shaking in a Nutshell
Whether tree-shaking works comes down to three things: you're using ESM, it isn't being converted to CommonJS, and your side effects are declared correctly. And keep in mind that it only actually removes code in production builds. Development mode usually keeps everything around to make debugging easier.
Step 3: Code Splitting, Defer What You Don't Need Yet
Tree-shaking deals with code you'll never use. But there's another kind of code: stuff you don't need right now but might need later. For example:
- The content of other routes, which only matters once the user navigates there
- A modal that only opens after a button click
- An admin-only dashboard chart
None of this has to crowd into the initial bundle. Code splitting breaks your bundle into multiple chunks and loads them on demand.
Dynamic import()
The core syntax is the dynamic import(). It returns a Promise, and when the bundler sees it, it automatically splits the target module into its own chunk:
button.addEventListener('click', async () => {
// the chart chunk is only downloaded after the click
const { renderChart } = await import('./chart.js')
renderChart(data)
})Route-Level Splitting
Routes are usually the best place to split. Users only look at one page at a time, so there's no reason to load every page's code upfront. Here's what it looks like in React:
import { lazy, Suspense } from 'react'
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
)
}Vue routes have a similar pattern: component: () => import('./pages/Dashboard.vue'). And Next.js, in both the App Router and the Pages Router, splits by page automatically out of the box, so there's nothing to do by hand.
Component-Level Splitting
Beyond routes, it's worth splitting out any big component that isn't needed for the first screen, like a rich text editor, a code editor, a map, or a charting library:
const Editor = lazy(() => import('./components/RichTextEditor'))
function Post({ editing }) {
return editing ? (
<Suspense fallback={<p>Loading editor...</p>}>
<Editor />
</Suspense>
) : (
<Article />
)
}What to Watch Out for After Splitting
More splitting isn't always better. Go too far and you pay a new price:
- Request waterfalls: chunk A loads and only then reveals that it needs chunk B, which in turn needs chunk C, and so on down the line.
- Interaction delay: if the download only starts once the user clicks, a slow network means a noticeable stall.
- Duplicated code: modules shared by several chunks get bundled multiple times if they aren't pulled out into a shared chunk. Bundlers usually handle this for you (webpack's
splitChunks, for example), but it's worth confirming in the analysis report.
To cut down on interaction delay, you can use preloading: quietly load a chunk when the user is likely to need it. Common triggers are hovering over a link or the page going idle.
// preload on hover, so the chunk is already cached by the time of the click
link.addEventListener('mouseenter', () => {
import('./pages/Settings')
})Putting It All Together: A Practical Workflow
Chain the three steps together and you get a slimming process you can repeat:
- Measure a baseline: run the bundle analysis once and write down the gzipped size of the homepage bundle and its biggest dependencies.
- Deal with the biggest block first: check whether it has a replacement. For example, swap moment.js for date-fns or dayjs, and lodash for lodash-es.
- Check tree-shaking: make sure your packages ship ESM,
sideEffectsis set correctly, and Babel isn't converting modules to CommonJS. - Split routes and heavy components: always split routes. For big components like charts and editors, split case by case.
- Measure again: compare before and after using the same report to confirm the optimization actually worked, and record the numbers.
The last step is the one people skip most often. Take it a step further and put a bundle size check in CI (with size-limit or bundlewatch, for example), so a PR fails when it blows past the budget. That keeps your project from quietly getting fat again.
Wrapping Up
Bundle optimization isn't mysterious. It really comes down to three questions:
- What's in there? Measure with an analysis tool first. Don't optimize by gut feeling.
- What's never used? Tree-shaking removes it, as long as you use ESM, avoid being converted to CommonJS, and declare
sideEffectscorrectly. - What's not used yet? Code splitting defers it. Start with routes, then move on to heavy components, and use preloading to win back the interaction delay.
One last reminder: the goal isn't a smaller bundle, it's a page that feels faster to your users. After every change, go back and check whether LCP and INP improved too. That's what makes all the effort worth it.