Tree shaking in practice: what actually ends up in your bundle
Tree shaking tested in 2026: 22 imports from lodash, date-fns, MUI, antd, Element Plus and more, built with esbuild, Rollup, Vite 8 and webpack. Where one import costs 1 KB, where it costs 300 KB, and which lines of library code break tree shaking.
Tree shaking – removing unused code at build time – works, just not the way library docs promise. I built 22 imports from popular packages with four bundlers: esbuild 0.28, Rollup 4.66, Vite 8.3 on Rolldown, and webpack 5.112. import { debounce } from 'lodash' costs about 25 KB gzipped in all four. import { ElButton } from 'element-plus' costs 10 KB in Vite 8 and webpack, but 272 KB in Rollup and 321 KB in esbuild, because the library's index file contains four lines of its own code. Path imports in MUI make no difference to production bundle size at all. Below are the tables, the mechanism behind each case, and a way to check your own project.
How I measured
Each case is a separate entry file with a single import. To stop the bundler from dropping the import as unused, the value is assigned to a global:
import { debounce } from 'lodash-es'
window.x = debounceTest setup
- Node.js 22.23.4, production builds with
process.env.NODE_ENV = "production", ESM output. - esbuild 0.28.2:
bundle,minify, platformbrowser. - Rollup 4.66.0:
@rollup/plugin-node-resolve16 with thebrowserfield,@rollup/plugin-commonjs29, minified with@rollup/plugin-terser1.0.2 (terser 5.51). - Vite 8.3.5: plain
vite buildwith defaults, i.e. Rolldown 1.2.16 and the Oxc minifier. - webpack 5.112.2:
mode: 'production', default terser-webpack-plugin. - React 19.3.1 and Vue 3.5.44 are external in every bundler, so the tables show library code only.
- Size is the sum of all output JS files, gzipped at level 9 with Node's
zlib. Builds are deterministic; reruns produce identical bytes. - Each result reflects the whole pipeline: the bundler plus its default minifier. I did not separate which of the two removed the code.
Why four bundlers: Vite 7 and earlier used Rollup for production builds, while Vite 8, released on March 12, 2026, replaced both Rollup and esbuild with Rolldown. Nuxt moved to Vite 8 in version 4.5. esbuild matters as a standalone bundler that many tools are built on, webpack because a huge number of existing projects still use it. I did not test Next.js's Turbopack.
How much does one import cost: utilities
Sizes in KB after gzip. Minified, uncompressed output is usually 2.5–4 times larger.
Import | esbuild | Rollup | Vite 8 | webpack |
|---|---|---|---|---|
| 26.2 | 24.6 | 25.1 | 24.6 |
| 1.6 | 1.3 | 1.4 | 1.4 |
| 1.4 | 1.0 | 1.0 | 1.0 |
all of lodash-es | 35.1 | 28.4 | 30.8 | 30.1 |
| 0.3 | 0.2 | 0.2 | 0.2 |
| 5.6 | 5.4 | 5.4 | 5.3 |
all of date-fns | 18.0 | 16.9 | 17.5 | 17.2 |
| 3.3 | 3.0 | 3.2 | 3.1 |
| 20.3 | 19.8 | 19.7 | 77.6 |
| 0.8 | 0.5 | 0.5 | 0.6 |
| 2.5 | 2.1 | 2.3 | 2.2 |
Versions: lodash and lodash-es 4.18.1, date-fns 4.4.0, dayjs 1.11.23, moment 2.31.0, @vueuse/core 15.0.0, rxjs 7.8.2.
Takeaways:
- lodash ships as CommonJS and no bundler can shake it: one function costs more than three quarters of the whole lodash-es.
lodash-esand the path importlodash/debouncegive the same result. - date-fns shakes almost perfectly, but
formatis more than 20 times heavier thanaddDays: it pulls in the default English locale and the format token parser. All of date-fns is 17–18 KB. - dayjs doesn't need tree shaking, it is one small function. The README advertises about 2 KB; with version 1.11.23 I measured 3.0–3.3 KB gzipped.
- moment is four times heavier in webpack: it loads locales with a dynamic
require('./locale/' + name). The webpack docs use moment as their example: since 2.18 all locales are bundled with the core, and the suggested fix isIgnorePlugin. The other three bundlers did not include that dynamic path.
How much does one import cost: icons and UI kits
Import | esbuild | Rollup | Vite 8 | webpack |
|---|---|---|---|---|
| 1.9 | 1.7 | 1.6 | 1.6 |
all of lucide-react | 206.7 | 187.7 | 197.0 | 177.5 |
| 1.4 | 1.3 | 1.3 | 1.3 |
| 29.9 | 26.4 | 26.6 | 26.1 |
| 29.8 | 26.4 | 26.6 | 26.1 |
| 37.5 | 34.6 | 34.8 | 34.4 |
| 37.6 | 34.8 | 35.0 | 34.4 |
| 56.4 | 36.4 | 38.3 | 37.8 |
| 321.2 | 271.7 | 9.7 | 9.8 |
| 14.6 | 9.7 | 9.7 | 9.8 |
| 19.7 | 17.6 | 18.2 | 17.6 |
Versions: lucide-react 1.64.0, react-icons 5.7.0, @mui/material and @mui/icons-material 9.5.0, antd 6.6.8, element-plus 2.14.7, naive-ui 2.45.3. Library CSS is not included.
| import from package root | path import of the component | |
|---|---|---|
| esbuild 0.28 | 321.2 KB | 14.6 KB |
| Rollup 4.66 | 271.7 KB | 9.7 KB |
| Vite 8 (Rolldown) | 9.7 KB | 9.7 KB |
| webpack 5.112 | 9.8 KB | 9.8 KB |
Takeaways:
- Icons cost 1–2 KB each. All of lucide-react is about 190 KB gzipped. For scale, the entire initial JS of this site's home page is 172 KB gzipped. An accidental
import * as iconscosts more than the rest of the app. - The first MUI icon costs 26 KB because it brings the base layer:
SvgIcon, the styling system and Emotion. A second icon added less than 0.1 KB gzipped. Components share the same base: an icon plus Button is 35.1–35.7 KB, less than 1 KB more than the button alone. - MUI path imports don't change production size.
{ Delete }from the root and/Deletediffer by at most 0.2 KB in every bundler. That matches the current MUI guide for v9: modern bundlers already shake production builds, and path imports are about development speed (more below). - antd is almost 20 KB heavier in esbuild than in the other three. If your project runs on Ant Design, like the coffee chain ERP in my portfolio, the bundler choice alone changes its cost by a third.
- Element Plus shows the largest spread in the test: 33x between bundlers. Its docs say manual imports like
import { ElButton } from 'element-plus'support tree shaking because the library uses ES modules. That's true in Vite 8 and webpack, not in Rollup and esbuild. The breakdown is below.
Why tree shaking isn't working
A bundler can only remove code when it is sure of two things: nobody uses the result, and the code changes nothing outside itself when it runs. If it's unsure about either, the code stays. Nearly every failure in the tables comes down to one of four causes.
1. CommonJS instead of ES modules
ES module import and export are static: you can tell from the file's text what it exports and what was taken from it. In CommonJS an export is just a property assignment on module.exports that can depend on runtime conditions. The esbuild docs state plainly that tree shaking works with ES modules and not with CommonJS.
Since version 5, webpack can shake some CommonJS: the webpack 5 announcement lists supported patterns such as exports.xxx = ... and Object.defineProperty(exports, "xxx", ...). If a module is written differently, webpack gives up on analysis. lodash is written differently – the whole package is one big wrapper function – so { debounce } from it doesn't shake in any bundler.
2. Top-level code in a module
Any top-level function call – const table = buildTable() – must either be proven harmless or kept. Bundlers differ in how much they can prove, and a synthetic test makes it visible.
I built a tiny library with an index file re-exporting two modules: small is used, heavy isn't. I put different constructs into heavy one at a time and checked whether it survived.
What the unused module contains | esbuild | Rollup | Vite 8 | webpack |
|---|---|---|---|---|
only a function declaration | removed | removed | removed | removed |
a top-level function call | kept | removed | kept | kept |
same + | removed | removed | removed | removed |
same + | removed | removed | removed | removed |
| kept | removed | removed | removed |
| kept | removed | kept | kept |
| removed | removed | removed | removed |
| kept | removed | kept | removed |
CommonJS ( | kept | kept | kept | removed |
In the test, buildTable fills a local array and returns it. Judging by the result, Rollup looks into the function body and sees it touches nothing outside. The others treat such a call as potentially unsafe. The Rolldown docs describe the same stance: when in doubt, keep the code.
Assigning a property to a component (Comp.Item = Item, Comp.displayName = ...) is a common UI-library pattern for compound components like Form.Item. esbuild doesn't remove such assignments, and that explains antd's extra 20 KB: the chain is antd/es/index.js → config-provider/index.js → tooltip/UniqueProvider → @rc-component/trigger. config-provider/index.js has top-level assignments like ConfigProvider.ConfigContext = ... and a call to Object.defineProperty(ConfigProvider, ...), so esbuild keeps all of ConfigProvider and everything it imports. Rollup drops that chain.
3. The sideEffects field: an accelerator and a landmine
The "sideEffects" field in package.json is the package author's promise that modules do nothing on import beyond declaring exports. The webpack docs call it a more effective optimization than regular tree shaking: the bundler can skip a module and its whole subtree without analyzing the code. false covers every file in the package; an array lists files that must be kept, usually CSS. The Rolldown docs describe the same logic.
In my test the flag rescues every unused-module case. The flip side is the last line of the experiment: a polyfill module that adds a method to String.prototype, with "sideEffects": false, was removed by all four bundlers without a warning. The webpack docs warn about exactly this: polyfills, global listeners, component registration and imported CSS silently disappear under a wrong flag, and a development build may not show it. Set the flag only on packages that have no such modules, or list those modules explicitly.
4. An index file with its own code
A barrel file is an index module that re-exports everything from a folder or package: export * from './button'. As long as it contains only re-exports, a bundler with the sideEffects flag can skip over it: the webpack docs describe exactly this – a side-effect-free module whose re-exports are the only thing used is skipped, and analysis continues directly in the source modules.
Element Plus shows what happens when a barrel contains something else. At the end of element-plus/es/index.mjs there are four lines: an import of the installer object from defaults.mjs and three lines reading its install and version properties. The installer itself is created by makeInstaller([...all components, ...all plugins]), and every component is wrapped at the top level in withInstall(...) with no /*#__PURE__*/ annotation.
I copied the package and deleted only those four lines from the index file. Result for ElButton:
Variant | esbuild | Rollup | Vite 8 | webpack |
|---|---|---|---|---|
Element Plus as published | 321.2 | 271.7 | 9.7 | 9.8 |
without four lines in | 18.0 | 12.5 | 9.7 | 9.8 |
Going by their docs, Rolldown and webpack skip the index file thanks to sideEffects and never reach those lines. esbuild and Rollup include its own code, follow the installer to the array of all components and pull the entire library into the bundle. I haven't pinned down the exact trigger inside esbuild and Rollup: on a simplified copy of the same pattern both remove the code, so it's a combination with something else in the package. The practical conclusion stands either way: a few lines of code in an index file can cost up to 300 KB.
Barrel files in dev mode: the problem isn't the bundle
Most writing about barrel files is about development speed, not bundle size. Tree shaking doesn't run in dev: the dev server loads modules as they are, so importing one icon from a package root means loading every module the barrel re-exports.
- When Vercel added
optimizePackageImportsin Next.js 13.5, they measured that importing from@material-ui/iconsin dev loaded 11,738 modules in 10.2 s, versus 632 modules in 2.9 s after rewriting imports. Forlucide-react: 1,583 modules and 5.8 s versus 333 modules and 3 s. - TkDodo describes a Next.js project whose pages loaded over 11,000 modules and took 5–10 seconds to start. After removing internal barrels, about 3,500 modules remained.
- The MUI guide warns that named imports from
@mui/icons-materialcan be up to six times slower in development than path imports.
So MUI path imports are worth it, but for the dev server and tests. In my measurements they make no difference to the production bundle.
How to check your project
- See what your bundle is made of. vite-bundle-analyzer works for Vite, Rollup and (experimentally) Rolldown, Vite 8 has build analysis in Vite DevTools, and webpack has webpack-bundle-analyzer. Look for packages that appear whole even though you import a couple of functions.
- Isolate the suspicious import. Create a file with that one import and
window.x = ..., as in my test, and build it with the same bundler and settings as your project. Compare against a path import or the package's ESM build. - Check the library's package.json. Is there an ESM build (
moduleor animportcondition inexports) and asideEffectsfield? Without ESM there is no tree shaking; withoutsideEffectsthe bundler has to prove every module pure on its own. - Trace the import chain to the heavy module. esbuild's
metafileoption records the imports of every file, which makes it easy to find the path from your entry to the heavy module. Open each file on that path and look for top-level code other thanimportandexport: calls, property assignments,Object.defineProperty.
Fixes, roughly from easiest:
- a path import (
lodash/debounce,element-plus/es/components/button/index.mjs) or the package's ESM version (lodash-es); - the auto-import setup the library itself recommends – for Element Plus,
unplugin-vue-componentswith its resolver; - in Next.js,
optimizePackageImportsfor packages not in the default list; - swapping the library: moment to dayjs or date-fns saves 17 KB even before locales;
- a different bundler or a newer version. Vite 8 builds with Rolldown instead of Rollup, and in my measurement that's the difference between 272 and 10 KB for Element Plus.
Summary
- Tree shaking only works on ES modules. CommonJS lodash costs 25 KB gzipped for a single function in every bundler;
lodash-escosts 1 KB. - The same import can differ by an order of magnitude between bundlers:
ElButtonfrom Element Plus is 10 KB in Vite 8 and webpack versus 272–321 KB in Rollup and esbuild. - An unused module stays in the bundle because of any top-level code the bundler can't prove harmless: calls,
Comp.Item = ...,Object.defineProperty./*#__PURE__*/and thesideEffectsfield fix it. - A careless
"sideEffects": falseis dangerous: polyfills, CSS and component registration vanish silently. - MUI path imports don't shrink the production bundle, but they speed up the dev server. Barrel files hurt development speed first and foremost.
- Don't trust a library's docs – check with an isolated one-import build in your own bundler.
Sources
- 01Tree Shakingwebpack
- 02Webpack 5 releasewebpack · 2020
- 03Dead Code EliminationRolldown
- 04Vite 8.0 is out!vite.dev · 2026
- 05API: Platform (tree shaking and CommonJS)esbuild
- 06Minimizing bundle sizeMUIMaterial UI v9
- 07Quick StartElement Plus
- 08How we optimized package imports in Next.jsVercel · 2023
- 09Please Stop Using Barrel FilesTkDodo · tkdodo.eu · 2024
- 10Nuxt 4.5Nuxt
- 11IgnorePluginwebpack