completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

A diet for dependencies

Weigh npm packages before you add them, use what the browser already has, and stop shipping polyfills your visitors don't need. Measured on a phone.

By the end, you can
  • 01
    Weigh a package firstRead Bundlephobia, weigh your own import line with esbuild, and measure what a package really adds to a page.
  • 02
    Reach for the browser's ownSwap a carousel, a date picker, axios and uuid for what the browser already has, and know what you give up.
  • 03
    Ship only what your visitors needRead a browser list, see which polyfills and rewrites it asks for, and spot legacy JavaScript in a build.

Meera's wishes come with packages

Meera has two new wishes for Leaf and Kettle. The first is for the tea list. At the top, she wants this month's teas in a strip. Asha can swipe through them, one tea at a time.

The second is for the checkout. Asha should pick the day her tea arrives. Then she taps Place order. The order goes to the shop's server, with a number of its own.

Ravi looks on npm, the big online shelf of JavaScript packages. Each wish has a popular package:

  • react-slick for the strip of teas. A strip you swipe like this is called a carousel.
  • react-datepicker for the delivery day.
  • axios to send the order to the server.
  • uuid to give each order a number no other order has.

A package your app needs is called a dependency: the app depends on it. Each of these is one npm install away. But Ravi remembers lesson 22.3. He asked lodash for one function, and the whole library came. So before he installs anything, he asks one question. What will each package cost Asha?

He tries them all. He builds the tea list with react-slick, and the checkout with the other three. He also builds both pages a second way, with what the browser already has. Then he loads each page on Asha's phone. Here is what each page sends, next to the 250 KB budget he set in lesson 22.1:

What each page of the shop sends on Asha's phone, with Brotli, against the 250 KB budget. Tea list: today, 111.1 KB, ready 2,267 ms; with packages, 125.3 KB, ready 2,335 ms; browser's own, 111.1 KB, ready 2,273 ms. Checkout: today, 84.0 KB, ready 2,083 ms; with packages, 144.8 KB, ready 2,294 ms; browser's own, 84.1 KB, ready 2,129 ms.250 KB budgetTea listtoday111.1 KB, ready 2,267 mswith packages125.3 KB, ready 2,335 msbrowser's own111.1 KB, ready 2,273 msCheckouttoday84.0 KB, ready 2,083 mswith packages144.8 KB, ready 2,294 msbrowser's own84.1 KB, ready 2,129 ms0 KB50100150200250
Each bar is one page's JavaScript on Asha's phone, sent with Brotli, and when the page could answer a tap. The packages added 14.2 KB to the tea list and 60.8 KB to the checkout. The browser's own ways added almost nothing.

Each bar is one page's JavaScript, sent with Brotli: the bytes that travel, after the server packs the file. A KB is 1,024 bytes. Beside each bar is when the page could answer a tap. As in lesson 22.1, that's the later of two moments: the biggest thing on the page appearing, and the last long task ending. A long task keeps the phone busy for more than 50 ms.

Both pages stay under the budget. But the packages made the checkout almost three quarters heavier. Now look at the third bar on each page: the browser's own way. Those pages weigh about the same as today.

"Asha's phone" is the stand-in from lesson 22.1: Chrome on a phone's screen, DevTools' Slow 4G network, and a CPU 4 times slower. Here are the numbers behind the packages' bars:

What the packages cost AshaPop out in the code editor (opens in a new tab)JavaScript
// Asha's phone: each page's JavaScript (KB, sent with Brotli), and when it could answer a tap (ms).const today = { "Tea list": [111.1, 2267], Checkout: [84.0, 2083] };const withPackages = { "Tea list": [125.3, 2335], Checkout: [144.8, 2294] }; for (const page of Object.keys(today)) {  const [kb, ms] = withPackages[page];  console.log(`${page}: ${(kb - today[page][0]).toFixed(1)} KB more, ready ${ms - today[page][1]} ms later`);}
  • Line 1 is a comment.
  • Line 2 is each page today, as lesson 22.4 left it. Line 3 is each page with the packages.
  • Lines 5 to 8 print Tea list: 14.2 KB more, ready 68 ms later and Checkout: 60.8 KB more, ready 211 ms later

Why it matters: a budget is a limit, not an allowance to spend. Every package costs Asha time. So weigh each one before you add it.

Real-life analogyReading the label before you buy

In a shop, you pick up a packet of biscuits. Before it goes in the basket, you turn it over. The label says how heavy it is, and what's inside. Sometimes the list of ingredients is long. And sometimes you already have the same thing at home.

In real life: A packet on the shop's shelf
In web performance: A package on npm
In real life: The weight on the label
In web performance: The bytes it adds, sent with Brotli
In real life: The list of ingredients
In web performance: The other packages it brings
In real life: Food you already have at home
In web performance: What the browser already has
The main idea

Every package you add costs your visitors bytes and time. Weigh it before you add it. Use what the browser already has. And build only for the browsers your visitors really use. That's a diet for dependencies: less JavaScript in, the same page out.

The lesson follows Ravi, one question at a time:

  • What does a package really weigh? Measuring it before you install it.
  • Does the browser already do it? Lighter ways to make the same wish.
  • What is a polyfill, and who asks for one? Polyfills and browser lists.
  • What do old browsers cost new ones? Legacy JavaScript.

Weigh a package before you install it

Ravi starts with react-slick. He doesn't install it yet. First, he looks it up on Bundlephobia. It's a website that builds a package the way a bundler would. Then it tells you how big the result is. Here is what it says about react-slick:

What Bundlephobia says about react-slickPop out in the code editor (opens in a new tab)JavaScript
// What Bundlephobia reports for react-slick 0.31.0, in bytes.const slick = { minified: 61816, gzipped: 15416, dependencies: 4, esModule: false }; console.log(`${(slick.gzipped / 1024).toFixed(1)} KB with gzip, ${slick.dependencies} dependencies`);console.log(slick.esModule ? "ES modules: it can be tree-shaken" : "CommonJS only: the bundler keeps all of it");
  • Line 1 is a comment. Line 2 holds four of Bundlephobia's facts.
  • minified is the size with spaces, comments and long names taken out. gzipped is that size packed with gzip, an older way to pack files than Brotli.
  • dependencies counts the packages react-slick needs. esModule says whether it's written as ES modules.
  • Lines 4 and 5 print 15.1 KB with gzip, 4 dependencies and CommonJS only: the bundler keeps all of it

The last line matters most. Lesson 22.3 showed why. A bundler can only leave out the unused parts of ES modules. react-slick is written as CommonJS, the older style, so it comes whole, as lodash did.

Bundlephobia gives a good first look. But it weighs the whole package. Ravi wants to weigh the line he would really write. So he writes a small script, measure.mjs:

measure.mjs: weigh one import lineJavaScript
import { build } from "esbuild";import { brotliCompressSync } from "node:zlib"; const result = await build({  stdin: { contents: process.argv[2], resolveDir: "." },  bundle: true, minify: true, format: "esm", write: false,  external: ["react", "react-dom"],});const code = result.outputFiles[0].contents;console.log(`${code.length} bytes minified, ${brotliCompressSync(code).length} with Brotli`);
  • Lines 1 and 2 bring in esbuild, Ravi's bundler since lesson 22.2, and Node's Brotli packer.
  • Lines 4 to 8 build one import line. Line 5 takes the line from the command, as if it were a file. esbuild calls a file given as text stdin.
  • Line 6 bundles everything the line imports, and minifies it. It keeps the result in memory, instead of writing a file.
  • Line 7 leaves out React and React DOM, because Ravi's pages load them already. react-slick lists them as its peer dependencies: packages it expects the app to have.
  • Lines 9 and 10 print the size, minified and with Brotli.

Ravi runs it for two packages:

Two runs of measure.mjs, and what each printedText
$ node measure.mjs 'import Slider from "react-slick"; console.log(Slider);'
65204 bytes minified, 14897 with Brotli
$ node measure.mjs 'import { v4 } from "uuid"; console.log(v4);'
789 bytes minified, 400 with Brotli

Line 1 weighs react-slick. Line 2 says 14.5 KB with Brotli, close to Bundlephobia's 15.1 KB with gzip. Line 3 weighs the line Ravi would write for uuid. Line 4 says only 400 bytes.

But Bundlephobia says uuid weighs 3.8 KB with gzip. Why so different? uuid is written as ES modules. And its package.json says "sideEffects": false, the promise from lesson 22.3. So the bundler kept only v4, the one function Ravi imports. Bundlephobia also lists the size of each export. Its list says v4 alone is 520 bytes with gzip.

Why it matters: weigh the import line you will write, not the whole package.

There's a third way, and it's the truest. Build the feature into the app, and compare the page before and after. The course did it for every package. react-slick added 14.2 KB to the tea list, close to what measure.mjs said. Read each row across: the way, what it weighs, and what it can't see.

Three ways to weigh a package
The wayWhat it weighsWhat it can't see
BundlephobiaThe whole package and what it brings, minified, then packed with gzip.Which parts your import line uses, and what your app already has.
measure.mjsYour own import line, bundled and minified by esbuild, then packed with Brotli.What the rest of your app already has, beyond React.
Built into the appWhat the page really sends, against the same page without it.Nothing about the page. But you have to write the feature first.

Bundlephobia counted 4 dependencies. But npm installed 5 packages with react-slick, because one dependency has one of its own. Here they are, as built into the tea list:

What npm install react-slick brings, as built into the tea list before Brotli: react-slick, 51.9 KB; it brings resize-observer-polyfill, 7.7 KB; lodash.debounce, 1.7 KB; classnames, 0.7 KB; json2mq, 0.4 KB; and json2mq brings string-convert, 0.1 KB.You installreact-slick, 51.9 KBIt bringsresize-observer-polyfill, 7.7 KBlodash.debounce, 1.7 KBclassnames, 0.7 KBjson2mq, 0.4 KBstring-convert, 0.1 KB
react-slick and the five packages npm installs with it, as built into the tea list, before Brotli. One of them, resize-observer-polyfill, fills in a feature every browser on Ravi's list already has.

react-slick's own code is the big card. Each small card is a package Ravi never chose. One of them is a polyfill. You'll meet it again soon.

Real-life analogyThe printer and its ink

A printer is on sale, and the price looks small. At home, you find it needs its own ink, its own cable and its own paper tray. The real cost is the printer and everything it needs.

In real life: The printer
In web performance: react-slick's own code
In real life: Its ink, its cable and its paper tray
In web performance: The packages npm installs with it
In real life: The price on the printer's box
In web performance: The size of the package alone
In real life: Everything you paid in the end
In web performance: What the tea list really sends: 14.2 KB more

One more trap. What npm puts on Ravi's computer isn't what Asha downloads. npm puts packages in a folder called node_modules. npm install axios added 27 packages there. But Asha's checkout got code from just one of them: axios itself. A tool called pkg-size measures that other size: what a package adds to node_modules. It's about Ravi's disk, not Asha's phone.

Weigh all five packages yourself. One of them is Embla, a lighter carousel library Ravi also tried:

Playground: weigh a package three ways
react-slick 0.31.0
  1. Bundlephobia: the whole package60.4 KB minified, 15.1 KB with gzip
  2. Packages npm install brings5: classnames, json2mq, string-convert, lodash.debounce, resize-observer-polyfill
  3. Written asCommonJS only: the bundler keeps all of it
  4. measure.mjs: your import line63.7 KB minified, 14.5 KB with Brotli
  5. Built into the shop14.2 KB more for the tea list, with Brotli
Try it yourself
Before npm install

Pick a package. The rows show what Bundlephobia reported, what npm installs with it, what measure.mjs printed for Ravi's import line, and what it really added to its page.

Bundlephobia's API, measure.mjs and the shop's builds, with esbuild 0.28.2. KB of 1,024 bytes.

Use what the browser already has

Ravi looks at the four wishes again. Can the browser already do any of them? A group of developers called e18e keeps a list of packages that have lighter replacements. Its list says axios can be replaced by fetch(), and uuid by crypto.randomUUID(). Both are built into the browser.

Real-life analogyA picnic table in the park

You're going for a picnic. You could carry a folding table from home. It's heavy, and your arms ache by the time you arrive. But the park already has picnic tables. They're not the table you would pick. Still, they hold the sandwiches just as well.

In real life: Your folding table from home
In web performance: A package, like react-datepicker
In real life: The park's own picnic table
In web performance: What the browser has, like a date field
In real life: Carrying the table there
In web performance: Asha's phone downloading and running the package
In real life: A table you didn't choose
In web performance: A calendar the browser draws its own way

A strip of teas, with no JavaScript

The browser can make a strip you swipe without any script. It's called scroll snap. The strip scrolls sideways, and it always stops with one tea lined up. Here is the carousel as a plain list:

teas-of-the-month.jsx, a plain listJSX
import { TEAS } from "./teas.js"; export function TeasOfTheMonth() {  return (    <ul className="month">      {TEAS.slice(0, 4).map((tea) => <li key={tea.name}>{tea.name}</li>)}    </ul>  );}

Line 1 brings in the shop's teas. Lines 5 to 7 draw the first four as a list. Two lines of CSS do the rest:

The CSS that makes the list snapCSS
.month { display: flex; gap: 8px; overflow-x: auto; scroll-snap-type: x mandatory; }
.month li { flex: 0 0 85%; scroll-snap-align: start; }
  • Line 1 puts the teas in a row with display: flex. overflow-x: auto lets the row scroll sideways. scroll-snap-type: x mandatory makes it stop at a tea every time.
  • Line 2 makes each tea 85% as wide as the strip. scroll-snap-align: start lines up each tea's left edge.

A delivery day from the browser

Here is the delivery day with react-datepicker, and then with the browser's own date field:

delivery-day.jsx, with react-datepickerJSX
import { useState } from "react";import DatePicker from "react-datepicker"; export function DeliveryDay() {  const [day, setDay] = useState(null);  return <DatePicker selected={day} onChange={setDay} placeholderText="Delivery day" />;}
delivery-day.jsx, with the browser's date fieldJSX
export function DeliveryDay() {  return <input type="date" name="day" aria-label="Delivery day" />;}

In the first, line 2 brings in react-datepicker. Line 5 keeps the day Asha picks, and line 6 draws the picker. In the second, line 2 is all it takes: <input type="date">. The browser draws the field and its calendar. MDN says the calendar's look depends on the browser and the system. And the value always comes out the same way: year, month, day.

Sending the order, and its number

Here are the two small files the checkout needs, first with packages:

The order's two helpers, with axios and uuidJavaScript
// send-order.jsimport axios from "axios"; export function sendOrder(order) {  return axios.post("/api/orders", order);} // order-id.jsimport { v4 as uuidv4 } from "uuid"; export const newOrderId = () => uuidv4();

Line 5 sends the order with axios. Line 11 makes its number with uuid. Here are the same two files with what the browser has:

The order's two helpers, with fetch and randomUUIDJavaScript
// send-order.jsexport function sendOrder(order) {  return fetch("/api/orders", {    method: "POST",    headers: { "Content-Type": "application/json" },    body: JSON.stringify(order),  });} // order-id.jsexport const newOrderId = () => crypto.randomUUID();
  • Lines 3 to 7 send the order with fetch(), the browser's own way to make a request. Line 4 asks for a POST, which sends data.
  • Line 5 tells the server the body is JSON. Line 6 turns the order into JSON text.
  • Line 11 makes the number with crypto.randomUUID(). It gives back a random ID in a standard form, called a UUID.

uuid itself knows about this. In its own source, v4() with no options just calls crypto.randomUUID(), if the browser has it.

What Asha's phone did

The course loaded each page three ways on Asha's phone. With scroll snap, the tea list sent 111.1 KB, the same as today. It could answer at 2,273 ms. With the browser's own ways, the checkout sent 84.1 KB, and answered at 2,129 ms. With the three packages, it answered at 2,294 ms.

So the packages cost the checkout 165 ms. Try each page and build:

Playground: what Asha's phone did with each page
What the phone did
  1. JavaScript sent with Brotli111.1 KB in 4 files
  2. JavaScript work on the main thread230 ms
  3. Could answer a tapat 2,267 ms
Try it yourself
Six visits, three times each

Pick a page and a build. The rows show what the page sent at once, how long its JavaScript kept the main thread busy, and when it could answer a tap.

From the visit in the middle of three, in Chrome 157 on a phone's screen, DevTools' Slow 4G, a CPU 4 times slower.

The browser's own ways have limits, though. Read each row across: the wish, the browser's way, and what you give up.

What the browser's own way gives up
The wishThe browser's wayWhat you give up
A strip of teas to swipeA list with CSS scroll snapNo dots or arrows, unless you add them.
A delivery day<input type="date">Its calendar looks different in each browser and system.
Sending the orderfetch()A server error doesn't reject the promise. You check response.ok yourself.
An order numbercrypto.randomUUID()It only works on pages served over HTTPS.

When the browser's way isn't enough, pick a lighter package. Embla added 7.1 KB to the tea list: half of react-slick's 14.2 KB.

Why it matters: before you add a package, check whether the browser already does the job.

Polyfills, and the browser list that picks them

Look again at the packages react-slick brought. One is called resize-observer-polyfill. What is a polyfill?

A polyfill is code that fills a gap in an older browser. It checks whether the browser has a feature. If not, it adds the feature, written in JavaScript. This one is for ResizeObserver, a way to hear when an element changes size. Here are its last lines:

The end of resize-observer-polyfillJavaScript
// resize-observer-polyfill 1.5.1, src/index.js, lines 4 to 11export default (() => {    // Export existing implementation if available.    if (typeof global.ResizeObserver !== 'undefined') {        return global.ResizeObserver;    }     return ResizeObserverPolyfill;})();
  • Line 1 names the file, in the package's own source.
  • Line 4 checks whether the browser already has ResizeObserver. If it does, line 5 hands back the browser's own.
  • Line 8 hands back the polyfill only when the browser has none.

Chrome has had ResizeObserver since version 64, Firefox since 69, and Safari since 13.1. On Asha's phone, line 5 runs, and the polyfill is never used. But her phone still downloads it: 7.7 KB as built. It's like a booster seat that stays in the car after the children have grown up.

Polyfills from the build

Polyfills also come from the build itself. Two tools work together:

  • core-js is a library of polyfills. Its README calls it "a modular standard library for JavaScript". Each feature has a small module of its own.
  • Babel is a tool that rewrites JavaScript so that older browsers can run it. With a plugin, it also adds the core-js polyfills the code needs.

Here is a Babel setup many apps use:

babel.config.jsonJSON
{  "targets": "defaults",  "presets": ["@babel/preset-env"],  "plugins": [["polyfill-corejs3", { "method": "usage-global", "version": "3.51" }]]}
  • Line 2 names the browsers to build for. This is a browser list. A tool called Browserslist reads it.
  • Line 3 is preset-env. It rewrites any syntax the browsers on the list can't run.
  • Line 4 adds polyfills. usage-global means: for each feature the code uses that some browser on the list lacks, add its polyfill to the page. Babel's docs say it works like the older useBuiltIns: "usage" setting. Since Babel 8, this plugin is the way to add them.

A browser list is written as a query, in almost plain English. Three examples:

  • ie 11 is one browser: Internet Explorer 11.
  • defaults is Browserslist's own choice. It takes browsers used by more than 0.5% of people, the last two versions of each browser, and Firefox's long-support version. Then it drops the browsers it calls dead: those with no updates for 24 months, like IE 11.
  • baseline widely available follows Baseline. A feature is Baseline widely available once Chrome, Edge, Firefox and Safari have all had it for 30 months. This query picks the browser versions that have all those features.
Real-life analogyA guest list decides the menu

You're cooking for a party, so you check the guest list. One guest can't eat nuts, so you make a dish without nuts too. But the list is old. That guest moved away years ago, and you still cook the extra dish every time.

In real life: The guest list
In web performance: The browser list, like defaults
In real life: What each guest can't eat
In web performance: What each browser lacks
In real life: The extra dish
In web performance: Polyfills and rewritten code
In real life: The guest who moved away
In web performance: A browser nobody uses any more, like IE 11

Ravi tries Babel on three lines of his own code:

Three lines of Ravi's codeJavaScript
const names = teas.map((tea) => tea.name);
const cheap = teas.filter((tea) => tea.price < 5);
const first = teas.at(0);

Line 1 makes a list of the teas' names. Line 2 picks the teas under £5. Line 3 takes the first tea. Here is what Babel wrote for defaults:

What Babel wrote, for defaultsJavaScript
import "core-js/modules/es.iterator.constructor.js";import "core-js/modules/es.iterator.filter.js";import "core-js/modules/es.iterator.map.js";const names = teas.map(tea => tea.name);const cheap = teas.filter(tea => tea.price < 5);const first = teas.at(0);
  • Lines 4 to 6 are Ravi's code, almost unchanged. The browsers Babel builds for can run it as it is.
  • Lines 1 to 3 are new. They import polyfills for iterator helpers: methods like map and filter on an iterator, an object that hands out values one at a time. Chrome 109, the oldest Chrome on the list, doesn't have them.

But teas is an array, not an iterator. Arrays have had map for many years. Babel can't tell which one teas is. It sees the word map, and adds the polyfill to be safe.

In the whole tea list, these guesses add up. React's scheduler is the part of React that decides when to run its work. It checks whether setImmediate exists, a function from Node.js and old Internet Explorer. Babel saw the name, and added a polyfill for it too.

Why it matters: Babel adds polyfills by the names in your code, not by what the code really needs. Try the three lists:

Playground: one browser list, one Babel output
What Babel wrote

import "core-js/modules/es.iterator.constructor.js"; import "core-js/modules/es.iterator.filter.js"; import "core-js/modules/es.iterator.map.js"; const names = teas.map(tea => tea.name); const cheap = teas.filter(tea => tea.price < 5); const first = teas.at(0);

  1. Oldest browsers Babel builds forChrome 124, Firefox 125, Safari 17.4, Safari on iOS 17.4, Edge 124
  2. Polyfills Babel imports3
Try it yourself
Three lines in, what comes out?

Pick a browser list. The box shows what Babel wrote for Ravi's three lines, with preset-env and the core-js polyfill plugin.

Babel 8.1.2, core-js 3.51.0, Browserslist 4.29.5.

What old browsers cost new ones: legacy JavaScript

Then Meera asks a fair question. "Will the shop work on my mum's old laptop?" Ravi doesn't know what browser it has. To be safe, he could build for IE 11, the last Internet Explorer. What would that cost Asha?

For IE 11, Babel has more to do. In the playground above, pick ie 11. Lines 1 to 9 import nine polyfills. And Ravi's three lines changed. The arrow function (tea) => tea.name became function (tea) { return tea.name; }. And const became var.

Rewriting new syntax into older syntax is called transpiling. Shipping old syntax and polyfills to browsers that don't need them is called legacy JavaScript.

Real-life analogySpelling out a text for Grandma

You text your friends in short forms, like "thx" and "c u l8r". Your grandma doesn't know them, so for her you spell every word out. Her message is much longer. Now imagine you sent the long one to everyone, just in case.

In real life: Short forms your friends know
In web performance: New syntax, like arrow functions
In real life: Spelling it out for Grandma
In web performance: Transpiling for IE 11
In real life: A longer message
In web performance: More bytes to send and to read
In real life: Sending the long one to everyone
In web performance: Shipping the IE 11 build to Asha's Chrome

The course built the whole tea list four ways. The first is Ravi's build today: esbuild alone. esbuild rewrites only syntax its target can't run. Its docs say it "does not automatically add polyfills". The other three went through Babel and core-js first, each for one list. Every build split the app in the same places, as lesson 22.4 did.

The tea list built four ways, on Asha's phonePop out in the code editor (opens in a new tab)JavaScript
// The tea list's first load on Asha's phone, built four ways:// KB sent with Brotli, core-js polyfills inside, and when it could answer a tap (ms).const builds = [  ["esbuild alone", 111.1, 0, 2267],  ["baseline widely available", 125.6, 22, 2421],  ["defaults", 126.9, 27, 2404],  ["ie 11", 174.6, 116, 2604],]; for (const [list, kb, polyfills, ms] of builds) console.log(`${list}: ${kb} KB, ${polyfills} polyfills, ready at ${ms} ms`);
  • Lines 1 and 2 are comments. Line 4 is esbuild alone. Lines 5 to 7 went through Babel for each list.
  • Line 10 prints one line for each build, from esbuild alone: 111.1 KB, 0 polyfills, ready at 2267 ms to ie 11: 174.6 KB, 116 polyfills, ready at 2604 ms
The tea list's first load on Asha's phone, built for four browser lists, sent with Brotli: esbuild alone, 111.1 KB, 0 polyfills, ready 2,267 ms; baseline widely available, 125.6 KB, 22 polyfills, ready 2,421 ms; defaults, 126.9 KB, 27 polyfills, ready 2,404 ms; ie 11, 174.6 KB, 116 polyfills, ready 2,604 ms.Sent with Brotliesbuild alone0 polyfills, ready 2,267 ms111.1 KBbaseline widely available22 polyfills, ready 2,421 ms125.6 KBdefaults27 polyfills, ready 2,404 ms126.9 KBie 11116 polyfills, ready 2,604 ms174.6 KB0 KB50100150200 KB
The same tea list, built for four lists of browsers. The older the list, the more polyfills it sends. Any polyfills made the page answer later, and the IE 11 build was the last to answer a tap.

Built for IE 11, the tea list sent 63.5 KB more than esbuild alone. Most of it was polyfills. Asha's phone could answer a tap 337 ms later. Even the modern list, baseline widely available, cost 14.5 KB and 154 ms. Babel can't see what the code needs, only the names it uses.

Why it matters: every visitor pays for the oldest browser on your list.

A check that finds it

Chrome's DevTools and Lighthouse have a check for this, called Legacy JavaScript. Its description says: "Polyfills and transforms enable older browsers to use new JavaScript features. However, many aren't necessary for modern browsers." It flags a script with more than about 5 KB of them, for features that Baseline calls widely available.

The course ran the check's own code on each build. In the IE 11 build, it named 31 polyfills and 3 of Babel's rewrites. In the defaults build, it found nothing, though that build held 27 polyfills. Its list only covers features every browser has had for years. So a clean result doesn't mean no polyfills.

IE 11 can't run this page anyway

There's a twist. Since lesson 22.4, Ravi's page loads its code like this: <script type="module" src="app.js">. That's a module script. MDN's data says IE 11 never ran module scripts. To IE 11, the tea list has no script at all.

So the IE 11 build helps nobody. Every byte of it lands on browsers that don't need it. Try the builds yourself. The fifth build did the IE 11 rewrites with no polyfills:

Playground: the tea list, built for each browser list
The tea list's first load
  1. Oldest browserswhatever runs ES2022
  2. Sent with Brotli111.1 KB
  3. core-js polyfills inside0
  4. The Legacy JavaScript check findsnothing
  5. JavaScript work on the main thread230 ms
  6. Could answer a tapat 2,267 ms
Try it yourself
One app, five builds

Pick a build. The rows show its oldest browsers, what the tea list sent at once, and its polyfills. Then what the Legacy JavaScript check found, and what Asha's phone did with it.

Built with Babel 8.1.2 and esbuild 0.28.2. Loaded three times each, in Chrome 157 on a phone's screen, DevTools' Slow 4G, a CPU 4 times slower.

Which list, then?

The honest answer is in the shop's own visitor data, as in Field data from every browser. Browserslist can read it too. A query like > 0.5% in my stats picks the browsers above 0.5% of the shop's own visits, from a file made from its analytics.

Beware of having no list at all. Babel's docs say that Babel 7 then built for all browsers. Babel 8 builds for defaults.

Ravi keeps esbuild alone for now. If he ever uses a feature some visitors lack, he'll add that one polyfill by hand. And he tells Meera the truth: he'll check which browsers the shop's visitors really use.

Why it matters: build for the browsers your visitors have, not the ones they had years ago.

The browser's own, a package, or nothing?

Here are jobs a shop's app might have. Sort each one by what it should use:

The browser's own, a package, or nothing?
  • A field to pick the delivery day
  • Sending the order to the shop's server as JSON
  • A random number for each new order
  • Writing "3 days ago" under a review
  • A review editor with bold, lists and links
  • Turning reviews written in Markdown into HTML
  • core-js polyfills for IE 11, in an app loaded with type="module"
  • A ResizeObserver polyfill, for a list that starts at Chrome 109
Try it yourself
0 of 8 correct

Sort each job by what it should use.

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

Common mix-ups

  • "Bundlephobia's size is what my page will send." No. It weighs the whole package. uuid was 3.8 KB with gzip there, and added 0.4 KB to the checkout.
  • "What npm installs is what visitors download." No. axios added 27 packages to node_modules. Asha's checkout got code from one of them.
  • "A polyfill only costs old browsers." No. Every visitor downloads it. The IE 11 build sent Asha's Chrome 63.5 KB more, for a browser that never runs the page.
  • "defaults means only the newest browsers." No. Babel read it as reaching back to Chrome 109, and added 27 polyfills to the tea list.
  • "If the Legacy JavaScript check passes, there are no polyfills." No. The defaults build passed with 27 polyfills inside.
  • "esbuild adds polyfills for its target." No. Its docs say the target is "only concerned with syntax features, not APIs".

Practice exercises

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

Exercise 1 · Warm-upTwo facts about an order number

What does this print? The string is random, but these two parts never change.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const id = crypto.randomUUID();
console.log(id.length, id[14]);

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

    Exercise 2 · Warm-upA server error, with fetch

    Ravi swapped axios for fetch. Here the server is down. What does the code print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // A stand-in for fetch(): the shop's server answers 503, "Service Unavailable".
    const fetch = async () => ({ ok: false, status: 503 });
    
    fetch("/api/orders", { method: "POST" }).then(
      (response) => console.log(`Placed: ${response.status}`),
      () => console.log("Failed"),
    );

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

      Exercise 3 · PracticeWhat react-datepicker brings

      How many packages does npm install with react-datepicker, not counting react-datepicker itself?

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

        Exercise 4 · PracticeIE 11's rewrites alone

        Built for IE 11 with no polyfills, how many KB did the tea list send at once, with Brotli?

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

          Exercise 5 · ChallengeThe IE 11 build in DevTools

          Open the copy of the tea list built for IE 11 in Chrome, with DevTools' Network panel open. How many JavaScript files does it load before you tap anything?

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

            Exercise 6 · ChallengeSee what your list picks

            Which command prints the browsers that your project's browser list picks?

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

              Check your understanding

              For each question, ask: what does Asha's phone really receive, and does it need it?

              The dependency diet quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Bundlephobia's size for uuid was far bigger than what import { v4 } from "uuid" added to the checkout. Why?

                Choose an answer to see the explanation.

              2. Question 2 of 7npm install axios put many packages into node_modules. Which of them sent code to Asha's checkout?

                Choose an answer to see the explanation.

              3. Question 3 of 7What does esbuild's --target=chrome109 do to Ravi's code?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does this check print, in a browser from this year?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const fill = () => console.log("The polyfill ran");
                if (typeof Array.prototype.at !== "function") fill();
                
                console.log([3, 4, 5].at(-1));

                Choose an answer to see the explanation.

              5. Question 5 of 7The tea list built for defaults held many core-js polyfills, yet the Legacy JavaScript check found nothing. Why?

                Choose an answer to see the explanation.

              6. Question 6 of 7Ravi's page loads app.js with <script type="module">. Why is an ie 11 browser list pure waste for it?

                Choose an answer to see the explanation.

              7. Question 7 of 7Which change gives the checkout a delivery day with no JavaScript package at all?

                Choose an answer to see the explanation.

              Key takeaways

              • Every package costs your visitors bytes and time. A budget is a limit, not an allowance.
              • Weigh a package before you install it: Bundlephobia first, then your own import line, then the page itself.
              • CommonJS packages come whole. ES modules with no side effects give you only what you import.
              • The browser already has a lot: scroll snap, <input type="date">, fetch() and crypto.randomUUID().
              • A polyfill fills a gap in older browsers, but every visitor downloads it. Babel adds them by name, not by need.
              • Build for the browsers your visitors really use. A stale list costs everyone.

              Remember the one-liner.
              Weigh it, swap it for the browser's own if you can, and build only for the browsers your visitors have.

              Sources

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

              Coming next: Minification and build output. Ravi's pages send less now. Next, look inside the files the build writes.

              CompleteFrontend Clear concepts. Working examples.