completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Tree shaking

Let your bundler drop code nobody imports. See why a CommonJS library comes whole, what sideEffects and pure notes promise, and what barrel files cost.

By the end, you can
  • 01
    Import so the bundler can shakeTell an ES module package from a CommonJS one, and pick the import line that brings in only what you use.
  • 02
    Use the promises packages makeRead and write sideEffects in package.json, and mark a top-level call that's safe to drop with a pure note.
  • 03
    Check that it workedRead what your bundler kept and why, avoid surprises from barrel files, and test the page, not just its size.

One function, and the whole library came with it

Meera has a new wish for the tea list: a search box. Asha types "mint", and the mint teas appear under the box.

Ravi doesn't want to search on every key press. Asha types four letters in under a second. He wants to wait until she stops typing for 300 ms, then search once. There's a well-known helper for that, called debounce. It lives in lodash, a big library of small helpers. Ravi adds one line, and builds:

Meera's search box, as Ravi first wrote itJSX
import { useMemo, useState } from "react";import { debounce } from "lodash"; export function Search({ teas }) {  const [found, setFound] = useState([]);  const onType = useMemo(() => debounce((text) => setFound(text.trim() ? teas.filter((tea) => tea.name.toLowerCase().includes(text.trim().toLowerCase())).slice(0, 5) : []), 300), [teas]);  return (    <section className="search">      <input type="search" placeholder="Search our teas" aria-label="Search our teas" onChange={(event) => onType(event.target.value)} />      <ul>        {found.map((tea) => <li key={tea.name}>{tea.name}</li>)}      </ul>    </section>  );}
  • Line 2 imports debounce from lodash.
  • Line 6 wraps the search in debounce, with a wait of 300 ms. Each key press starts the wait again. The search runs once Asha stops.
  • Lines 9 to 11 draw the box, and up to five teas that match.

The box works. Then Ravi checks app.js against his budget from lesson 22.1, as he always does now:

What the search box costPop out in the code editor (opens in a new tab)JavaScript
// app.js sent with Brotli, in bytes: before the search box, and with it.const before = 178240;const withLodash = 202183; const added = withLodash - before;console.log(`The search box added ${(added / 1024).toFixed(1)} KB.`);console.log(`That's ${Math.round((added / before) * 100)}% more JavaScript.`);
  • Line 2 is app.js as lesson 22.2 left it, sent with Brotli: 174.1 KB.
  • Line 3 is the same file with the search box.
  • Lines 6 and 7 print The search box added 23.4 KB. and That's 13% more JavaScript.

The search box's own code is about half a kilobyte. So where did the rest come from? Ravi opens the treemap, the way lesson 22.2 taught him:

Left, the tea list on Asha's phone with mint typed into the search box and five teas found. Right, a treemap of app.js with the search box, 755.4 KB as built: src/features 364.4 KB, react-dom 205.0 KB, lodash 71.7 KB, the rest 114.5 KB. The lodash box is shaded: it is new.What Asha seesThe tea list with mint typed into the search box, and five teas listed under itWhat Ravi sees: app.jssrc/features364.4 KBreact-dom205.0 KBlodash71.7 KBthe rest114.5 KB
A real screen of the search box, and app.js with it, as a treemap. The shaded box is new: lodash, the whole library, for one function.

There's a new box: lodash, 71.7 KB as built. That's the whole library, more than 300 functions. Ravi asked for one.

This surprises him. The Bundlers & build tools lesson said bundlers do tree shaking: they leave out the parts of your packages that nothing uses. So why didn't it happen here? And how can he make it happen?

Real-life analogyPacking from a list

A friend packs your suitcase from a list you wrote. If the list says "one scarf", they pack one scarf, not the whole drawer. But if the scarf is in a sealed box with other things, they can't open it. They pack the whole box.

In real life: The suitcase
In web performance: app.js
In real life: The list you hand over
In web performance: The import lines in your files
In real life: Things left at home
In web performance: Exports nothing imports
In real life: A sealed box you can't open
In web performance: A package whose parts the bundler can't see
The main idea

Tree shaking means the bundler leaves out code that nothing uses. It only leaves out what it can prove is unused, and safe to skip. ES modules show it what's used. Notes from the package's author show it what's safe.

This lesson follows Ravi, one question at a time:

  • Why did lodash come whole? What a bundler can see before the code runs.
  • How does a package say a file is safe to skip? sideEffects in package.json.
  • Why does an unused value stay? Pure notes.
  • Why read 640 files for one function? Barrel files.
  • How do you know it worked? What each bundler can tell you.

What a bundler can see before the code runs

A bundler never runs your code. It reads it. Then it decides what goes into app.js. So the question is: from reading alone, can it tell which parts you use?

For lodash, it can't. lodash is written as CommonJS, the older module style from Node.js. A CommonJS file hands out its exports through an object called module.exports. That object is built while the file runs. Here is a small model of the idea:

A model of CommonJS: exports made while the file runsPop out in the code editor (opens in a new tab)JavaScript
// A small model of a CommonJS file: its exports are made while it runs.const lodash = {};for (const name of ["debounce", "chunk", "shuffle"]) {  lodash[name] = () => `${name} ran`;} const wanted = "deb" + "ounce";console.log(lodash[wanted]());
  • Line 2 makes an empty object, the way a CommonJS file starts with an empty export object.
  • Lines 3 to 5 fill it with three functions. This only happens when the code runs.
  • Line 7 builds a name from two pieces.
  • Line 8 picks a function by that name, and prints debounce ran.

Read line 8 without running anything. Which function will it pick? You can't be sure. Neither can a bundler. So it keeps all three. lodash works the same way, with more than 300 functions instead of three. Its main file, lodash.js, is one big function call that builds them all, then hands them out.

Real-life analogyA sealed hamper, or a deli counter

You want a piece of cheese. One shop only sells it in a sealed gift hamper, with jam, crackers and wine. You pay for all of it. The deli next door keeps each food in its own labelled tub. You point at the cheese, and that's all you carry home.

In real life: The sealed hamper
In web performance: lodash: one CommonJS file, built as it runs
In real life: The deli counter, a tub for each food
In web performance: lodash-es: one ES module file for each function
In real life: The label on each tub
In web performance: An export line with a fixed name
In real life: Asking for just the cheese
In web performance: import { debounce } from "lodash-es"

ES modules, the import and export lines, are the deli counter. The JavaScript standard fixes their shape:

  • An import or export line can only stand at the top level of a file, never inside a function or an if.
  • The file it names is a plain string, written in the code.
  • The names it brings in are written in the code too.

So a bundler can read every import line before anything runs. It knows which names each file uses. That fixed shape is what people mean by static imports. Here are the first lines of debounce.js in lodash-es, lodash written as ES modules:

debounce.js names what it needsJavaScript
// The first lines of lodash-es/debounce.js.
import isObject from './isObject.js';
import now from './now.js';
import toNumber from './toNumber.js';

Lines 2 to 4 each name one file and one value. Those files name their own imports in the same way. The bundler follows the names, and stops there. Here are three ways Ravi could ask for debounce:

Three import lines for one functionJavaScript
import { debounce } from "lodash";
import { debounce } from "lodash-es";
import debounce from "lodash/debounce";
  • Line 1 is what he wrote: the CommonJS library.
  • Line 2 asks lodash-es, the ES module version.
  • Line 3 asks for one small CommonJS file. lodash ships each function in its own file too, like lodash/debounce.js.

The course built the tea list with each line. Here is what debounce brought into app.js each time, to scale:

What debounce brought into app.js, as built, each square to scale: import from "lodash": 71.7 KB, 1 file; import from "lodash-es": 2.4 KB, 14 files; import "lodash/debounce": 2.9 KB, 14 files.from "lodash"71.7 KB, 1 filefrom "lodash-es"2.4 KB, 14 files"lodash/debounce"2.9 KB, 14 files
Each square is as big as the bytes debounce brought into app.js, as built. One function, three import lines.

With lodash-es, debounce brought 2.4 KB, from 14 small files. With the single file, 2.9 KB. Ravi changes his line to lodash-es. app.js is now 175.2 KB sent, 1.1 KB more than before the search box. Why it matters: the same function can cost thirty times more, just from the import line.

One trap is left. lodash-es also has a default export: one object, _, with every function attached. If you write import _ from "lodash-es" and call _.debounce, you ask for that object. The course tried it: 91.4 KB of lodash-es, from 618 files. But import * as _ from "lodash-es" is fine. That's a list of the named exports, and _.debounce uses one fixed name.

Is this just esbuild? No. The course built a small program that only uses debounce with four bundlers: esbuild, Rollup, webpack and Rolldown. Rolldown is the bundler Vite builds with. Try each import line:

Playground: one import line, four bundlers
The import line

import { debounce } from "lodash";

  1. esbuild72.1 KB as built, 23.4 KB sent
  2. Rollup69.4 KB as built, 21.8 KB sent
  3. Rolldown (Vite)70.0 KB as built, 22.4 KB sent
  4. webpack69.5 KB as built, 21.8 KB sent
  5. Files esbuild kept2
  6. Still prints the searchyes, with all four
Try it yourself
The same small program, built four times

The program calls debounce twice and prints one line. Pick how it imports debounce. Each row is one bundler's minified output: its size as built, and with Brotli.

Built with esbuild 0.28.2, Rollup 4.68.2, Rolldown 1.3.2 and webpack 5.112.2. Node ran every output to check it still printed the search.

All four kept the whole of lodash, and all four shook lodash-es. Every output still printed its search.

The sideEffects promise in package.json

To give Ravi debounce, esbuild read 640 lodash-es files and kept 14. How did it know the other 626 were safe to leave out?

Reading the import lines isn't enough. In ES modules, importing a file runs the code at its top level, even if you use nothing from it. That code might do something. Work like this is called a side effect: something that happens just because a file was loaded. Here are some examples:

  • changing a global value,
  • adding styles to the page,
  • registering an HTML element of its own.

So a bundler may only drop an unused file if loading it does nothing. The Modules through a bundler lesson follows a bundler as it makes this choice, step by step. lodash-es tells the bundler its files do nothing as they load. It takes one line in its package.json: "sideEffects": false. It's a promise from the package's author: "If you use nothing from one of my files, you can skip it. Loading it does nothing else."

Node.js ignores this field. It's a convention that webpack started, and esbuild, Rollup's package plugin and Rolldown read it too. What happens without it? The course took the line out of a copy of lodash-es, and built again:

  • app.js went from 175.2 KB to 203.2 KB sent.
  • esbuild kept 618 of the 640 lodash-es files: 91.4 KB as built.

Without the promise, the bundler has to check every file's top-level code itself. Many lodash-es files do run code as they load. For example, _root.js calls a function to find the page's global object. The bundler can't tell that this is harmless, so those files stay, with everything they use. Why it matters: one line in someone else's package.json saved Ravi 28.0 KB.

Ravi's own promise

Next, Meera wants stars on each tea, for how strong it is. The shop has a small kit of shared parts, called tea-kit. One of its files draws stars:

tea-kit/stars.jsJavaScript
export class TeaStars extends HTMLElement {  connectedCallback() {    const stars = Number(this.getAttribute("value"));    this.textContent = "★".repeat(stars) + "☆".repeat(5 - stars);  }} customElements.define("tea-stars", TeaStars);
  • Lines 1 to 6 describe a custom element: an HTML tag of your own, here <tea-stars>.
  • Lines 3 and 4 run when the tag lands on the page. They read its value and write that many stars.
  • Line 8 registers the tag with the browser. Without it, <tea-stars> is an empty box.

Line 8 is a side effect. It runs when the file loads, and nothing imports a name for it. Ravi adds import "tea-kit/stars.js" to app.jsx. That's an import with no names, just to run the file. Then he puts a <tea-stars> tag in each tea's row.

Ravi liked what the promise did for lodash-es. So he adds it to the kit too, and builds:

tea-kit/package.json, with Ravi's promiseJSON
{  "name": "tea-kit",  "version": "1.0.0",  "type": "module",  "main": "index.js",  "sideEffects": false}

Line 6 is the promise. Here is the tea list on Asha's phone, built with it, and then with the fix below:

The tea list on Asha's phone, built twice. With "sideEffects": false in the kit's package.json, the rows have no stars. With "sideEffects": ["./stars.js"], every row shows its stars."sideEffects": falseThe tea list, with no stars under the tea namesNo stars"sideEffects": ["./stars.js"]The tea list, each tea with a row of stars under its nameStars
Real screens from the recording. The same app.jsx, built twice. Only one line of the kit's package.json changed.

The stars are gone. The build gave no error. The bundler believed the promise: nobody used anything from stars.js, so it skipped the file, and line 8 with it. Every one of the four bundlers did the same. esbuild warns about this when the import is in one of your own files: "Ignoring this import because … was marked as having no side effects". It doesn't warn about imports inside packages.

Real-life analogyBoxes to open on moving day

Movers carry forty boxes into your new home. Most can stay shut until you need a pan or a book. But one box holds houseplants. They need water today. Your note says which boxes to open on day one. If it says "none", the plants wilt.

In real life: The boxes
In web performance: The files of a package
In real life: Boxes that can wait until you need them
In web performance: Files the bundler may skip if nothing uses them
In real life: The houseplants
In web performance: stars.js: it has to run when it loads
In real life: The list of boxes to open on day one
In web performance: "sideEffects": ["./stars.js"]

The fix is to say which files do have side effects. "sideEffects" can be a list of them instead of false:

tea-kit/package.json, with the file listedJSON
{  "name": "tea-kit",  "version": "1.0.0",  "type": "module",  "main": "index.js",  "sideEffects": [    "./stars.js"  ]}

Lines 6 to 8 say: every file may be skipped, except stars.js. The stars came back, on every row. A kit with CSS files lists them here too, as "*.css". Importing a CSS file is a side effect: it adds styles.

One more detail. Import the file that has the side effect yourself, as Ravi did. The kit's main file re-exports TeaStars, so going through it seems to work too. It did in esbuild. But Rollup, Rolldown and webpack dropped the line when nothing imported stars.js directly. Compare the three:

Playground: does the line that registers the stars survive?
The import in the program

import "tea-kit/stars.js";

  1. esbuilddrops the line: no stars
  2. Rollupdrops the line: no stars
  3. Rolldown (Vite)drops the line: no stars
  4. webpackdrops the line: no stars
Try it yourself
Line 8 of stars.js, in each bundler's output

Pick what the kit's package.json says, and how the program reaches stars.js. Each row says whether that bundler kept customElements.define, the line that makes the stars appear.

Built with esbuild 0.28.2, Rollup 4.68.2, Rolldown 1.3.2 and webpack 5.112.2, each output searched for the line.

Marking one call as safe to drop

The kit also has a better search. Its findTeas matches each word Asha types on its own, in any order. So "mint gold" finds Gold mint tea. Ravi swaps it in. app.js grows by 16.7 KB as built. But findTeas is four lines. Here is its file:

tea-kit/search.jsJavaScript
import { ALL_TEAS } from "./teas-data.js"; function buildIndex(teas) {  const index = new Map();  for (const tea of teas) {    for (const word of tea.name.toLowerCase().split(" ")) {      index.set(word, [...(index.get(word) ?? []), tea]);    }  }  return index;} export const teaIndex = buildIndex(ALL_TEAS); export function findTeas(teas, text) {  const words = text.trim().toLowerCase().split(" ");  return teas.filter((tea) => words.every((word) => tea.name.toLowerCase().includes(word)));}
  • Line 1 imports the kit's own copy of the shop's 300 teas.
  • Lines 3 to 11 build an index: for each word in a tea's name, the teas with that word.
  • Line 13 calls buildIndex as the file loads, and exports the result as teaIndex.
  • Lines 15 to 18 are findTeas, the part Ravi uses.

Ravi never uses teaIndex. But line 13 is a call, and the bundler can't be sure buildIndex does nothing else. So it keeps the call. The call needs buildIndex and all 300 teas, so they stay too.

Can the sideEffects list help? No. It's about whole files, and this file is used: Ravi imports findTeas from it. He needs a note on one call instead. Bundlers read a special comment for this:

Line 13, with a pure noteJavaScript
export const teaIndex = /* @__PURE__ */ buildIndex(ALL_TEAS);

Line 13 now has a comment, /* @__PURE__ */, right before the call. It tells the bundler: "If nobody uses this call's result, you may remove the call." The name is confusing. It doesn't say the function is pure in the maths sense. esbuild's docs call it a shorthand for "ok to be removed if unused".

Real-life analogyA note on the dish in the office fridge

Someone left a big pot of soup in the office fridge. Nobody eats it. The cleaner won't throw it away: maybe someone still needs it. Then the cook adds a note: "Bin this if nobody eats it by Friday." On Friday, it's gone.

In real life: The big pot of soup
In web performance: buildIndex(ALL_TEAS), run as the file loads
In real life: Nobody eating it
In web performance: Nobody reading teaIndex
In real life: The note: "Bin this if nobody eats it"
In web performance: /* @__PURE__ */
In real life: The cleaner
In web performance: The bundler

The kit's author adds the note. The course built the tea list again:

What the note left outPop out in the code editor (opens in a new tab)JavaScript
// app.js as built, in bytes: with findTeas, without the note and with it.const withoutNote = 719351;const withNote = 702295; console.log(`The note left out ${((withoutNote - withNote) / 1024).toFixed(1)} KB.`);
  • Lines 2 and 3 are app.js as built, with findTeas: without the note, and with it.
  • Line 5 prints The note left out 16.7 KB.

The kit's part of app.js went from 17.0 KB in 3 files to 313 bytes in 2. Sent with Brotli, the two came within 0.1 KB of each other. Tea names repeat a lot, and app.js already held the same 300 teas. So Brotli packed the copy small. But the phone still unpacks those bytes, reads them, and runs buildIndex on every visit. As lesson 22.2 showed, code that ran isn't code that was needed.

Two details. The note covers the call only, not its arguments: an argument that does something still runs. And JSX gets these notes for free: esbuild adds them to every JSX tag it turns into a call. Try the note in all four bundlers:

Playground: one note on one call
Line 13 of search.js

export const teaIndex = buildIndex(ALL_TEAS);

  1. esbuild16.8 KB as built, 1.2 KB sent
  2. Rollup16.8 KB as built, 1.3 KB sent
  3. Rolldown (Vite)16.8 KB as built, 1.2 KB sent
  4. webpack16.8 KB as built, 1.2 KB sent
  5. Holds the 300 teasyes, with all four
Try it yourself
A program that only uses findTeas

It imports findTeas from tea-kit/search.js and never reads teaIndex. Pick whether line 13 has the note. Each row is one bundler's output.

Built with esbuild 0.28.2, Rollup 4.68.2, Rolldown 1.3.2 and webpack 5.112.2. "Holds the 300 teas" means the kit's copy of the teas is in the output.

The two notes are easy to mix up. Read each row across: where it goes, what it lets the bundler drop, and what breaks if it's wrong.

Two notes for the bundler
The noteWhere it goesWhat it lets the bundler dropIf it's wrong
"sideEffects" in package.jsonIn the package's own package.json, once.Whole files nobody uses anything from.A file that does work as it loads is dropped, like the stars.
/* @__PURE__ */ before a callIn the code, right before one call.That one call, when nobody uses its result.Work that call had to do is skipped.

Barrel files: one import, hundreds of files

Back to that number: esbuild read 640 lodash-es files to give Ravi one function. Why so many? Because Ravi imported from "lodash-es", which means its main file, lodash.js. That file is a barrel file: a file that only re-exports things from other files, so you can import them all from one place.

lodash-es/lodash.js, shortenedJavaScript
export { default as add } from './add.js';export { default as after } from './after.js';export { default as ary } from './ary.js';// ... 318 more lines like these ...export { default } from './lodash.default.js';
  • Lines 1 to 3 each pass on one function from its own file.
  • Line 4 stands for 318 more lines like them. The real file has 322 export lines.
  • Line 5 passes on the default export, the big _ object, from lodash.default.js.

Importing a file runs every file it re-exports from. That's how ES modules work. So to know what the barrel does, a bundler must open every file it names:

Two ways to import debounce from lodash-es. Through the barrel file lodash.js, esbuild read every file it names: 640 lodash-es files. Straight to debounce.js, it read only debounce.js and the files it imports: 14.Through the barrelsearch.jsxlodash.jsadd.jsdebounce.jszipWith.js… and 636 more files640 lodash-es files readStraight to the filesearch.jsxdebounce.jsisObject.jsnow.jstoNumber.js… and 10 more files14 lodash-es files read
Through the barrel, the bundler opens every file the barrel names. Straight to debounce.js, it opens only what debounce needs.
Real-life analogyRinging every doorbell

You bring a parcel to one flat in a block of 640. The rule at the main door: anyone who comes in rings every bell. The flat also has its own side door. Through it, you ring one bell.

In real life: The main door of the block
In web performance: The barrel file, lodash.js
In real life: Ringing every bell on the way in
In web performance: Reading every file the barrel names
In real life: The flat's own side door
In web performance: A direct path, like lodash-es/debounce.js

Going straight to the file is the side door:

Through the barrel, or straight to the fileJavaScript
import { debounce } from "lodash-es";
import debounce from "lodash-es/debounce.js";

Line 1 goes through lodash.js. Line 2 names debounce.js itself. Through the barrel, esbuild read 640 lodash-es files. Straight to the file, it read 14.

With lodash-es's promise, both lines kept the same 14 files. Without the promise, they differ a lot. Through the barrel, the bundler must keep the code of line 5's lodash.default.js, because importing the barrel runs it. That file imports every function and attaches each one to the _ object, in 304 lines like lodash.after = func.after;. Each of those lines changes an object, so each one stays, with its function. The direct path never touches that file. Try all four:

Playground: through the barrel, or straight to the file
The import line

import { debounce } from "lodash-es";

  1. lodash-es files esbuild read640
  2. lodash-es files it kept14
  3. esbuild2.9 KB as built
  4. Rollup2.2 KB as built
  5. Rolldown (Vite)2.2 KB as built
  6. webpack2.3 KB as built
Try it yourself
lodash-es as published, with "sideEffects": false

Pick whether lodash-es keeps its promise, and whether the program imports debounce through lodash.js or straight from debounce.js. The first two rows come from esbuild's metafile.

Built with 4 bundlers. esbuild also keeps lodash's license comment from lodash.js, 504 bytes, when it reads that file.

Reading hundreds of files is extra work for the bundler, too. It matters most during development. Vite's guide says a barrel makes its dev server fetch and transform every file it names, so the page loads slower. Some tools rewrite barrel imports for you:

  • Next.js has optimizePackageImports, still experimental. It loads only the modules you use from the packages you list. lodash-es is on its default list.
  • Rolldown has a lazy barrel option, also experimental. In a package with the promise, it skips compiling the files nobody asked for.

Why it matters: in your own code, import from the file itself. For packages, prefer ones that make the promise. And when a package doesn't, the direct path still works.

Checking that tree shaking worked

Each problem in this lesson was quiet. No build failed. So how do you check? Ask the bundler what it kept, then ask why. Then test the page.

Ask what it kept. esbuild's --analyze lists each file in the output with its bytes. Here it is for the program that only uses debounce, first with lodash:

esbuild --analyze, importing from lodashText
  lodash.js                         72.1kb  100.0%
   ├ node_modules/lodash/lodash.js  71.1kb   98.5%
   └ entries/lodash.js                97b     0.1%

Line 1 is the whole output. Line 2 is lodash.js: 98.5% of it. One file is nearly everything. Now with lodash-es:

esbuild --analyze, importing from lodash-es (the first lines)Text
  lodashEs.js                                    2.9kb  100.0%
   ├ node_modules/lodash-es/debounce.js          940b    31.8%
   ├ node_modules/lodash-es/toNumber.js          345b    11.7%
   ├ node_modules/lodash-es/_getRawTag.js        210b     7.1%
   ├ node_modules/lodash-es/_baseGetTag.js       156b     5.3%
   ├ node_modules/lodash-es/_root.js             100b     3.4%
   ├ node_modules/lodash-es/_trimmedEndIndex.js   92b     3.1%
   ├ node_modules/lodash-es/isSymbol.js           91b     3.1%

Line 1 is the whole output. Lines 2 to 8 are its biggest files, with debounce.js first. If you see one huge file where you expected a few small ones, tree shaking didn't happen. The treemap from lesson 22.2 shows the same thing as boxes.

Ask why. webpack's stats can list which exports of each module are used, and why an optimization gave up. The course asked for both:

webpack's stats, two modulesText
./node_modules/lodash/lodash.js
  CommonJS bailout: exports is used directly at 440:50-57
  Statement (ExpressionStatement) with side effects in source code at 9:1-17259:14
./node_modules/lodash-es/debounce.js
  usedExports: ["default"]
  • Lines 1 to 3 are about lodash.js. Line 2 says it uses exports directly, the CommonJS way. Line 3 says the whole file, from line 9 to line 17,259, is one statement with side effects. That's the one big function call.
  • Lines 4 and 5 are about lodash-es's debounce.js. Only its default export, the function, is used.

Rollup can say why it kept a file. With experimentalLogSideEffects, it logs the first side effect it finds in each file. On lodash-es without its promise, it logged 91 files. Here is one:

Rollup's log for _root.jsText
node_modules/lodash-es/_root.js (7:0): First side effect in node_modules/lodash-es/_root.js is at (7:0)
7: var root = freeGlobal || freeSelf || Function('return this')();

Line 1 names the file and the place. Line 2 shows the code: a call to Function, as the file loads.

Test the page. The stars build was smaller, and broken. Sizes can't show that. webpack's guide gives the same advice: build with production settings, then check that styles and behaviour still work. The course loaded the tea list in Chrome 157:

  • With lodash and with lodash-es, "mint" found the same 5 teas.
  • With "sideEffects": false, all 8 rows had an empty <tea-stars>, and the browser had never registered the tag.
  • With the file listed, all 8 rows had their stars.

Each bundler has its own way to tell you. Read each row across: if you build with this, ask for that, and here is what you learn.

Where each bundler tells you what it kept
If you build withAsk forWhat it tells you
esbuild--metafile and --analyzeEvery file it read, and the bytes each one put in the output.
webpackStats with usedExports and optimizationBailoutWhich exports of each module are used, and why an optimization gave up.
RollupexperimentalLogSideEffectsThe first side effect it found in each file.
Any of themThe treemap from lesson 22.2Which packages, and how many bytes of each, ended up in the file.

What stopped the shaking?

Each line below keeps code in app.js that nobody needs. Sort it by what stopped the bundler:

What stopped the shaking?
  • const _ = require("lodash");
  • A package whose only file ends with module.exports = helpers;
  • lodash-es with its "sideEffects": false line taken out
  • A kit's index.js re-exports 40 components, and its package.json says nothing about side effects
  • export const teaIndex = buildIndex(ALL_TEAS);, and nobody reads teaIndex
  • export const theme = createTheme({ dark: true });, and nobody reads theme
  • import _ from "lodash-es"; then _.debounce(...)
  • helpers[name](), where name is typed by the visitor
Try it yourself
0 of 8 correct

Sort each line by what stopped the bundler from leaving code out.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "Bundlers tree shake every package." No. They have to see the exports. esbuild only shakes ES modules, and lodash, a CommonJS library, came whole in all four bundlers.
  • "Adding sideEffects: false to my app fixes a big library." No. The field describes the files of the package that holds it. lodash is still one CommonJS file.
  • "A pure note means the function is pure." No. It only means: you may remove this call if nobody uses its result.
  • "import * as _ ships the whole library." No. With fixed names like _.debounce, it shook as well as a named import. The default import, import _, is the one that ships everything.
  • "A smaller build means it worked." No. The build without stars was smaller, and broken.
  • "Barrel files always make app.js bigger." No. With the package's promise, the barrel and the direct path kept the same files. The barrel made the bundler read far more.

Practice exercises

Answer each one first, then open the hints if you need them.

Exercise 1 · Warm-upThe single-file import

Ravi tries the third import line, lodash/debounce. What does this sum print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// app.js sent with Brotli, in bytes: before the search box, and with lodash/debounce.
const before = 178240;
const withPerMethod = 179562;

console.log(`${((withPerMethod - before) / 1024).toFixed(1)} KB more`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPicking by a name made at run time

    The CommonJS model again, with a different name. What does it print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const lodash = {};
    for (const name of ["debounce", "chunk", "shuffle"]) {
      lodash[name] = () => `${name} ran`;
    }
    
    const wanted = ["chunk", "shuffle"][1];
    console.log(lodash[wanted]());

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeThe default import, with Rollup

      With import _ from "lodash-es", how many KB did Rollup's build weigh, sent with Brotli?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeNo promise, with Rolldown

        Without lodash-es's promise, through the barrel, how many KB as built was Rolldown's output?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeIs the tag registered?

          Open the copy of the tea list built with "sideEffects": false. In DevTools' Console, run customElements.get("tea-stars"). What does it show?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeA note on the function itself

            Instead of a note before each call to buildIndex, the kit could put one note on the function itself. What does that note say?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask: can the bundler see it, and has someone promised it's safe?

              The tree shaking quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Why did import { debounce } from "lodash" bring the whole library into app.js?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this sum about the barrel file print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // esbuild and the debounce program, through lodash-es's barrel file.
                const read = 640;
                const kept = 14;
                console.log(`${read - kept} files read and left out`);

                Choose an answer to see the explanation.

              3. Question 3 of 7What does "sideEffects": false in a package's package.json promise?

                Choose an answer to see the explanation.

              4. Question 4 of 7Ravi's stars vanished after he added "sideEffects": false to tea-kit. Why?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does /* @__PURE__ */ in front of a call tell the bundler?

                Choose an answer to see the explanation.

              6. Question 6 of 7With import _ from 'lodash-es', about what share of app.js was lodash-es, as built?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // app.js as built, in bytes, with "import _ from 'lodash-es'".
                const appJs = 793694;
                const lodashEs = 93633;
                console.log(Math.round((lodashEs / appJs) * 100));

                Choose an answer to see the explanation.

              7. Question 7 of 7Which check would have caught the missing stars before Asha did?

                Choose an answer to see the explanation.

              Key takeaways

              • Tree shaking leaves out code nothing uses, but only what the bundler can prove is unused and safe to skip.
              • ES modules let a bundler read every import before the code runs. A CommonJS library like lodash comes whole.
              • "sideEffects" in a package's package.json lets the bundler skip whole files. List the files that do work as they load, like stars.js and CSS.
              • /* @__PURE__ */ lets the bundler drop one top-level call whose result nobody uses.
              • A barrel file makes the bundler read every file it names. Without the promise, it may keep them too.
              • Check what the bundler kept and why. Then test the page: a wrong promise makes the file smaller and the page wrong.

              Remember the one-liner.
              The bundler drops what it can see is unused and was promised is safe; ES modules let it see, and package notes make the promise.

              Sources

              The facts on this page come from these docs, specifications and source code:

              Coming next: Code splitting. Tree shaking dropped what no page uses. Next, move what only some pages use into files of their own.

              CompleteFrontend Clear concepts. Working examples.