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.
- 01Import 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.
- 02Use 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.
- 03Check 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:
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
debouncefrom 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:
// 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.jsas 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.andThat'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:
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?
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
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?
sideEffectsin 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 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.
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:
// 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:
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:
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:
import { debounce } from "lodash";
- esbuild72.1 KB as built, 23.4 KB sent
- Rollup69.4 KB as built, 21.8 KB sent
- Rolldown (Vite)70.0 KB as built, 22.4 KB sent
- webpack69.5 KB as built, 21.8 KB sent
- Files esbuild kept2
- Still prints the searchyes, with all four
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.
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:
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
valueand 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:
{ "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 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.
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:
{ "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:
import "tea-kit/stars.js";
- esbuilddrops the line: no stars
- Rollupdrops the line: no stars
- Rolldown (Vite)drops the line: no stars
- webpackdrops the line: no stars
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.
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:
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
buildIndexas the file loads, and exports the result asteaIndex. - 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:
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".
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:
// 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:
export const teaIndex = buildIndex(ALL_TEAS);
- esbuild16.8 KB as built, 1.2 KB sent
- Rollup16.8 KB as built, 1.3 KB sent
- Rolldown (Vite)16.8 KB as built, 1.2 KB sent
- webpack16.8 KB as built, 1.2 KB sent
- Holds the 300 teasyes, with all four
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.
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.
| The note | Where it goes | What it lets the bundler drop | If it's wrong |
|---|---|---|---|
"sideEffects" in package.json | In 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 call | In 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.
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, fromlodash.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:
debounce.js, it opens only what debounce needs.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:
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:
import { debounce } from "lodash-es";
- lodash-es files esbuild read640
- lodash-es files it kept14
- esbuild2.9 KB as built
- Rollup2.2 KB as built
- Rolldown (Vite)2.2 KB as built
- webpack2.3 KB as built
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.
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:
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:
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:
./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 usesexportsdirectly, 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:
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.
| If you build with | Ask for | What it tells you |
|---|---|---|
| esbuild | --metafile and --analyze | Every file it read, and the bytes each one put in the output. |
| webpack | Stats with usedExports and optimizationBailout | Which exports of each module are used, and why an optimization gave up. |
| Rollup | experimentalLogSideEffects | The first side effect it found in each file. |
| Any of them | The treemap from lesson 22.2 | Which 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:
const _ = require("lodash");- A package whose only file ends with
module.exports = helpers; - lodash-es with its
"sideEffects": falseline 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 teaIndexexport const theme = createTheme({ dark: true });, and nobody reads themeimport _ from "lodash-es";then_.debounce(...)helpers[name](), where name is typed by the visitor
Sort each line by what stopped the bundler from leaving code out.
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.
Ravi tries the third import line, lodash/debounce. What does this sum print?
// 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`);1.3 KB more. lodash/debounce.js is one small CommonJS file, so the bundler takes it and the 13 small files it needs, not the whole library.
The CommonJS model again, with a different name. What does it print?
const lodash = {};
for (const name of ["debounce", "chunk", "shuffle"]) {
lodash[name] = () => `${name} ran`;
}
const wanted = ["chunk", "shuffle"][1];
console.log(lodash[wanted]());shuffle ran. The name is only known when line 6 runs. A bundler reading this file can't tell which function it needs, so it keeps all three.
With import _ from "lodash-es", how many KB did Rollup's build weigh, sent with Brotli?
24.2 KB sent. The default export is one object with every function on it, so Rollup kept nearly all of lodash-es too.
Without lodash-es's promise, through the barrel, how many KB as built was Rolldown's output?
83.3 KB as built. Without the promise, Rolldown also had to keep lodash.default.js and everything it attaches to the _ object.
Open the copy of the tea list built with "sideEffects": false. In DevTools' Console, run customElements.get("tea-stars"). What does it show?
undefined. The bundler dropped stars.js, so line 8 never ran and the tag was never registered. Each <tea-stars> stays an empty element.
Instead of a note before each call to buildIndex, the kit could put one note on the function itself. What does that note say?
/* @__NO_SIDE_EFFECTS__ */
function buildIndex(teas) {
// ...
}
export const teaIndex = buildIndex(ALL_TEAS);/* @__NO_SIDE_EFFECTS__ */. It marks the function, so you don't need a pure note at every call. The course built search.js this way: all four bundlers left out buildIndex and the teas, as with the pure note.
Check your understanding
For each question, ask: can the bundler see it, and has someone promised it's safe?
Question 1 of 7Why did
import { debounce } from "lodash"bring the whole library into app.js?Choose an answer to see the explanation.
Question 2 of 7What does this sum about the barrel file print?
Read the code, then predict// 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.
Question 3 of 7What does
"sideEffects": falsein a package's package.json promise?Choose an answer to see the explanation.
Question 4 of 7Ravi's stars vanished after he added
"sideEffects": falseto tea-kit. Why?Choose an answer to see the explanation.
Question 5 of 7What does
/* @__PURE__ */in front of a call tell the bundler?Choose an answer to see the explanation.
Question 6 of 7With
import _ from 'lodash-es', about what share of app.js was lodash-es, as built?Read the code, then predict// 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.
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, likestars.jsand 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:
- Tree Shaking, webpack.
- Introduction: Tree-Shaking, Rollup.
- esbuild API: Tree shaking, Evan Wallace.
- Modules: CommonJS modules, module.exports, Node.js.
- ECMAScript Language Specification: Modules, Ecma TC39.
- Getting Started, Vite.
- Optimization: sideEffects, webpack.
- esbuild API: Ignore annotations, Evan Wallace.
- Modules: Packages, package.json fields, Node.js.
- Configuration options: treeshake.moduleSideEffects, Rollup.
- esbuild 0.28.2: bundler.go, the warning about ignored imports, Evan Wallace.
- CustomElementRegistry: define(), MDN.
- esbuild API: Pure, Evan Wallace.
- Configuration options: treeshake.annotations, Rollup.
- Dead Code Elimination, Rolldown.
- Performance: Avoid Barrel Files, Vite.
- optimizePackageImports, Next.js.
- Lazy Barrel Optimization, Rolldown.
- esbuild API: Analyze, Evan Wallace.
- esbuild API: Legal comments, Evan Wallace.
- Stats: optimizationBailout and usedExports, webpack.
- Configuration options: experimentalLogSideEffects, Rollup.
Coming next: Code splitting. Tree shaking dropped what no page uses. Next, move what only some pages use into files of their own.