Skip to content

13 min read

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:

JavaScript
import { debounce } from 'lodash-es'
window.x = debounce
Test setup
  • Node.js 22.23.4, production builds with process.env.NODE_ENV = "production", ESM output.
  • esbuild 0.28.2: bundle, minify, platform browser.
  • Rollup 4.66.0: @rollup/plugin-node-resolve 16 with the browser field, @rollup/plugin-commonjs 29, minified with @rollup/plugin-terser 1.0.2 (terser 5.51).
  • Vite 8.3.5: plain vite build with 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

import { debounce } from 'lodash'

26.2

24.6

25.1

24.6

import debounce from 'lodash/debounce'

1.6

1.3

1.4

1.4

import { debounce } from 'lodash-es'

1.4

1.0

1.0

1.0

all of lodash-es

35.1

28.4

30.8

30.1

import { addDays } from 'date-fns'

0.3

0.2

0.2

0.2

import { format } from 'date-fns'

5.6

5.4

5.4

5.3

all of date-fns

18.0

16.9

17.5

17.2

dayjs

3.3

3.0

3.2

3.1

moment

20.3

19.8

19.7

77.6

import { useDebounceFn } from '@vueuse/core'

0.8

0.5

0.5

0.6

import { map } from 'rxjs'

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-es and the path import lodash/debounce give the same result.
  • date-fns shakes almost perfectly, but format is more than 20 times heavier than addDays: 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 is IgnorePlugin. 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

import { Camera } from 'lucide-react'

1.9

1.7

1.6

1.6

all of lucide-react

206.7

187.7

197.0

177.5

import { FaBeer } from 'react-icons/fa'

1.4

1.3

1.3

1.3

import { Delete } from '@mui/icons-material'

29.9

26.4

26.6

26.1

import Delete from '@mui/icons-material/Delete'

29.8

26.4

26.6

26.1

import { Button } from '@mui/material'

37.5

34.6

34.8

34.4

import Button from '@mui/material/Button'

37.6

34.8

35.0

34.4

import { Button } from 'antd'

56.4

36.4

38.3

37.8

import { ElButton } from 'element-plus'

321.2

271.7

9.7

9.8

element-plus/es/components/button/index.mjs

14.6

9.7

9.7

9.8

import { NButton } from 'naive-ui'

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.

One ElButton from Element Plus in four bundlersGzipped JS size: import from the package root vs. a path import of the component
One ElButton from Element Plus in four bundlers
import from package rootpath import of the component
esbuild 0.28321.2 KB14.6 KB
Rollup 4.66271.7 KB9.7 KB
Vite 8 (Rolldown)9.7 KB9.7 KB
webpack 5.1129.8 KB9.8 KB

Source: own measurement, Element Plus 2.14.7, Vue external

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 icons costs 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 /Delete differ 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 + "sideEffects": false

removed

removed

removed

removed

same + /*#__PURE__*/ before the call

removed

removed

removed

removed

Comp.Item = Item

kept

removed

removed

removed

Object.defineProperty(Comp, ...)

kept

removed

kept

kept

static defaults = { ... } in a class

removed

removed

removed

removed

static defaults = mk() in a class

kept

removed

kept

removed

CommonJS (exports.x = ...)

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 index.mjs

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 optimizePackageImports in Next.js 13.5, they measured that importing from @material-ui/icons in dev loaded 11,738 modules in 10.2 s, versus 632 modules in 2.9 s after rewriting imports. For lucide-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-material can 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

  1. 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.
  2. 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.
  3. Check the library's package.json. Is there an ESM build (module or an import condition in exports) and a sideEffects field? Without ESM there is no tree shaking; without sideEffects the bundler has to prove every module pure on its own.
  4. Trace the import chain to the heavy module. esbuild's metafile option 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 than import and export: 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-components with its resolver;
  • in Next.js, optimizePackageImports for 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-es costs 1 KB.
  • The same import can differ by an order of magnitude between bundlers: ElButton from 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 the sideEffects field fix it.
  • A careless "sideEffects": false is 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

  1. 01Tree Shakingwebpack
  2. 02Webpack 5 releasewebpack · 2020
  3. 03Dead Code EliminationRolldown
  4. 04Vite 8.0 is out!vite.dev · 2026
  5. 05API: Platform (tree shaking and CommonJS)esbuild
  6. 06Minimizing bundle sizeMUIMaterial UI v9
  7. 07Quick StartElement Plus
  8. 08How we optimized package imports in Next.jsVercel · 2023
  9. 09Please Stop Using Barrel FilesTkDodo · tkdodo.eu · 2024
  10. 10Nuxt 4.5Nuxt
  11. 11IgnorePluginwebpack