completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Analyzing your bundles

See what's inside your JavaScript bundle: read a treemap, trace bytes with source maps, find packages bundled twice, and spot code that never runs.

By the end, you can
  • 01
    Read a treemap of your bundleGet your bundler's stats or metafile, find the biggest parts, and know which size each box shows.
  • 02
    Trace bytes back, and find duplicatesUse a source map to see which file each byte came from, and use npm ls to see why a package is in twice.
  • 03
    Sort out code that never runsRead Coverage for JavaScript, and tell code for later from code for other pages and code no page needs.

What's inside the tea list's app.js?

Meera's four features are live. Reviews under each tea. A quiz that picks a tea for you. Recipes. A chat helper. This time, Ravi built them with packages from npm. He added them to the 150 KB tea list from lesson 22.1.

Then he checks app.js against the budget he set in that lesson: 250 KB, compressed. app.js is now 265.2 KB. It's over.

So Ravi asks a plain question. What's actually in it? He wrote only a few small files for the new features. Yet the file is 1,179.9 KB before compression. Where did the rest come from?

To answer, he has to open the file up. app.js is a bundle: one file that a tool called a bundler makes from many. It holds Ravi's own files and every package they import. Ravi's bundler is esbuild, and it can write down what went in. Here is what Ravi saw, drawn as a treemap:

A treemap of the tea list's app.js, 1,179.9 KB as built. One box for each part, its area its share of the file. moment: 374.5 KB. src/features: 364.4 KB. react-dom: 205.1 KB. lodash: 72.0 KB. marked 18: 49.0 KB. marked 17: 40.3 KB. the rest: 74.6 KB. The two boxes for marked are shaded.Treemap of dist/app.jsmoment374.5 KBsrc/features364.4 KBreact-dom205.1 KBlodash72.0 KBmarked 1849.0 KBmarked 1740.3 KBthe rest74.6 KB
Each box's area is that part's share of app.js, as built. moment is the biggest box. marked is in there twice: the two shaded boxes.

A treemap is a rectangle cut into boxes. Each box is one part of the file. The bigger the box, the more bytes that part takes. Here are the four biggest boxes as numbers:

The four biggest parts of app.jsPop out in the code editor (opens in a new tab)JavaScript
// The four biggest parts of app.js, in KB as built (before compression).const appJs = 1179.9;const parts = { moment: 374.5, "src/features": 364.4, "react-dom": 205.1, lodash: 72 }; for (const [name, kb] of Object.entries(parts)) {  console.log(`${name}: ${Math.round((kb / appJs) * 100)}% of app.js`);}
  • Line 1 is a comment. A KB is 1,024 bytes, as in lesson 22.1. As built means the file the bundler wrote, before the server compresses it.
  • Line 2 is the whole of app.js as built: 1,179.9 KB.
  • Line 3 holds the four biggest boxes from the treemap.
  • Lines 5 to 7 print each one's share: moment: 32% of app.js, src/features: 31% of app.js, react-dom: 17% of app.js and lodash: 6% of app.js.

Two of those are known. react-dom is React's part that draws the page. src/features is the made-up features for the shop's other pages, from lesson 22.1. But the other two are a surprise. moment is a library for dates. Ravi used it for one small thing: the "10 months ago" under each review. It's the biggest box in the file. And lodash, a toolbox of small functions? Ravi doesn't remember adding it. One more thing stands out in the picture: two boxes are both called marked.

Why it matters: the biggest boxes are where a fix saves the most. You can't see them without opening the file.

Real-life analogyUnpacking an overweight suitcase

At the airport, your suitcase is 3 kg over the limit. Staring at the bag won't help. So you open it and lay everything out, biggest things first. There's a winter coat, packed for a beach trip. There are two hair dryers: yours, and the one your sister packed.

In real life: The suitcase on the scale
In web performance: app.js, checked against its 250 KB budget
In real life: Everything laid out, biggest first
In web performance: The treemap
In real life: A winter coat for a beach trip
In web performance: Date words for 139 languages and places, when Asha needs one
In real life: Two hair dryers
In web performance: Two copies of marked
The main idea

Bundle analysis means opening the built file to see what's inside it, and where each part came from. Then you fix the biggest parts, the doubled parts and the parts that never run, in that order.

The lesson follows Ravi, one question at a time:

  • What takes the space? Treemaps from the bundler.
  • Which file did these bytes come from? Source maps.
  • Why is a package in twice? npm ls.
  • Which code never runs? Coverage.
  • What did the fixes save? Ravi measures each one.

Reading a treemap of your bundle

A bundler knows exactly what it put in each file. It just doesn't say so unless you ask. Ravi asks esbuild with two more flags on his build:

Build app.js, and describe itShell
npx esbuild src/app.jsx --bundle --minify --outfile=dist/app.js \  --metafile=meta.json --analyze
  • Line 1 is the build: bundle src/app.jsx and everything it imports, minify it, and write dist/app.js. To minify means to rewrite code shorter, with the same meaning: short names, no spaces, no comments.
  • Line 2 adds the two flags. --metafile=meta.json writes a file of facts about the build, called the metafile. --analyze prints a short report from it.

Here are the first lines of the report for Ravi's app.js:

What esbuild's analysis printed (the first lines)Text
  dist/app.js                                                           1.2mb  100.0%
   ├ node_modules/moment/min/moment-with-locales.js                   374.5kb   31.7%
   ├ node_modules/react-dom/cjs/react-dom-client.production.js        201.0kb   17.0%
   ├ node_modules/lodash/lodash.js                                     72.0kb    6.1%
   ├ node_modules/marked/lib/marked.esm.js                             49.0kb    4.2%
   ├ node_modules/marked-react/node_modules/marked/lib/marked.esm.js   40.3kb    3.4%
   ├ node_modules/html-entities/dist/esm/named-references.js           33.1kb    2.8%

Each line is one input file: its bytes in app.js, and its share of the file. The first line is app.js itself. Then comes moment-with-locales.js, at 374.5 KB. Its name says what Ravi imported: moment, with every locale. A locale is the words and date formats for one language and place, like British English. This file holds 139 of them.

The metafile itself is plain JSON. For each output file, it lists every input file and its bytesInOutput: how many bytes of the output came from that input.

Three entries from the metafilePop out in the code editor (opens in a new tab)JavaScript
// Three entries from the metafile esbuild wrote for app.js, in bytes.const app = {  bytes: 1208193,  inputs: {    "node_modules/moment/min/moment-with-locales.js": { bytesInOutput: 383522 },    "node_modules/lodash/lodash.js": { bytesInOutput: 73706 },    "node_modules/marked-react/node_modules/marked/lib/marked.esm.js": { bytesInOutput: 41291 },  },};for (const [file, input] of Object.entries(app.inputs)) {  console.log(`${((input.bytesInOutput / app.bytes) * 100).toFixed(1)}% ${file}`);}
  • Line 1 is a comment.
  • Line 3 is bytes: the size of app.js.
  • Lines 5 to 7 are three of its inputs, each with its bytes in app.js.
  • Lines 10 to 12 print each share: 31.7% node_modules/moment/min/moment-with-locales.js, 6.1% node_modules/lodash/lodash.js and 3.4% node_modules/marked-react/node_modules/marked/lib/marked.esm.js.

Look at the last path. It has node_modules in it twice. Keep it in mind for later: it's one of the two copies of marked.

Why is lodash in there?

The metafile also keeps every import: which file imported which. esbuild's longer report, --analyze=verbose, uses them to show the chain from the first file down to each input. For lodash, the chain is short:

  • src/app.jsx imports src/old-quiz.jsx.
  • src/old-quiz.jsx imports node_modules/lodash/lodash.js.

The old quiz! Meera replaced it with a new one months ago. Why it matters: the chain tells you which of your own files to change.

Which size is each box?

A treemap can size its boxes in three ways. webpack-bundle-analyzer, a popular treemap for the webpack bundler, names them:

  • stat: the input file as it was, before minifying.
  • parsed: the part's bytes in the built file. This is its default, and what Ravi's treemap shows.
  • gzip (or Brotli): those parsed bytes, compressed.

Ravi's budget counts compressed bytes. So which compressed size belongs to each part? There's no exact answer. Brotli packs the whole file at once. It finds text that repeats in the file, and writes it once. So the tools pack each part on its own, as an estimate.

The course did the same for app.js. Each part, packed on its own, added up to 281,395 bytes. The whole file, packed at once, was 271,540. It's like vacuum-packing each jumper in its own bag: the bags together take more room than one big bag.

Why it matters: use the treemap to find the big parts. Use the whole file's compressed size to check the budget.

Each popular bundler has a treemap tool. Read each row across: if you build with this, use that, and here is what it reads.

A treemap for each bundler
If you build withUseWhat it reads
webpackwebpack-bundle-analyzer, a plugin.webpack's stats, and the built files.
Vite or Rolluprollup-plugin-visualizer, a plugin.Rollup's list of the modules in each chunk.
esbuildIts Bundle Size Analyzer page, or --analyze for text.The metafile that --metafile writes.
Next.jsnext analyze, from the lesson Shrinking the JavaScript bundle.The app, compiled the way a production build would be.
Any of them, with source mapssource-map-explorer.The built file and its source map.

For a Next.js app, Shrinking the JavaScript bundle shows next analyze on a real project. Now explore Ravi's treemap yourself. Tap a box to see its compressed size and the imports that brought it in:

Playground: a treemap of app.js
The treemap
moment
  1. Version2.31.0
  2. As built374.5 KB, 31.7% of app.js
  3. Compressed alone62.6 KB
  4. Files1
  5. Brought in bysrc/app.jsx → src/reviews.jsx
Try it yourself
app.js before the fixes: 1,179.9 KB as built, 265.2 KB sent

Each box is one part of app.js, as big as its bytes as built. Tap a box to see its size packed alone with Brotli, and the imports that brought it in.

Built with esbuild 0.28.2. The sizes come from its metafile; the compressed sizes pack each part's own bytes on their own.

Tracing bytes back with source maps

A treemap needs the bundler's own notes. What if you only have the built file? Say it's a script from another team, or from a site you're checking. Minified code is hard to read. Here are 40 characters from the middle of app.js:

40 characters of app.js, as builtText
fineLocale("az",{months:"yanvar_fevral_m

Whose code is that? You might guess from the words. A tool can't. It needs a source map: a second file that the bundler writes next to the built one, here app.js.map. It ties each run of bytes in app.js back to a place in an original file: the file, the line and the column. Its format is a standard, called ECMA-426.

Real-life analogyA box of chocolates and its card

A box of chocolates comes with a card. From above, the chocolates look alike. The card has a small map of the box: this spot is the hazelnut praline, that one is the salted caramel. Without the card, you'd have to bite each one to find out.

In real life: The box of chocolates
In web performance: app.js, minified
In real life: The card that comes with the box
In web performance: app.js.map, the source map
In real life: Third row, second spot: hazelnut praline
In web performance: One mapping: a spot in app.js, and its file, line and column
In real life: Counting the chocolates from each maker
In web performance: A source map explorer adding up bytes per file

Here is the outside of Ravi's source map:

The outside of app.js.mapJSON
{  "version": 3,  "sources": ["../node_modules/react/cjs/react.production.js", "../node_modules/react/index.js", "..."],  "sourcesContent": ["...", "...", "..."],  "mappings": "...",  "names": ["..."]}
  • Line 2: version is always 3.
  • Line 3: sources lists the original files. This map lists 213.
  • Line 4: sourcesContent holds the text of each file, so tools can show it. It's optional.
  • Line 5: mappings is one long string of short codes, 1,114,600 characters here. Each code says: this column of app.js came from this file, this line, this column. This map holds 193,069 of them.
  • Line 6: names lists original names that the mappings can point to: 8,095 of them.

The whole map was 4,063,320 bytes, more than three times app.js. About 70% of it was sourcesContent. And how do tools find the map? app.js ends with one comment, //# sourceMappingURL=app.js.map, that points to it.

Here are three bytes of app.js, followed back through the map:

app.js from its first byte to its last, 1,208,193 bytes, as runs from each part: react-dom from byte 12,603 to 222,618, moment from byte 223,032 to 606,554, lodash from byte 606,554 to 680,260, src/teas.js from byte 680,285 to 697,200, src/features from byte 697,200 to 1,070,303, marked 18 from byte 1,071,250 to 1,121,424, marked 17 from byte 1,122,000 to 1,163,509, html-entities from byte 1,163,509 to 1,198,791. The source map traces three bytes back: byte 316,302 to moment-with-locales.js, line 6933; byte 651,003 to lodash.js, line 7817; byte 1,132,897 to marked 17: rules.ts, line 438.byte 0byte 1,208,193react-dommomentsrc/featuresmoment-with-locales.jsline 6933lodash.jsline 7817marked 17: rules.tsline 438
app.js, laid out from its first byte to its last. The source map ties every run of bytes to a file and a line. Three bytes are followed back here.

The first byte is the one from the 40 characters above. It sits on line 6933 of moment-with-locales.js. That line starts moment's locale for Azerbaijani. The third byte leads somewhere new: rules.ts, a TypeScript file inside marked. esbuild read the source map that marked ships in its own package, so the trail goes all the way back to marked's own source. Move along app.js and follow any byte back:

Playground: follow a byte back to its file
app.js at this byte

fineLocale("az",{months:"yanvar_fevral_m

  1. Byte of app.js316,302 of 1,208,193
  2. Partmoment
  3. Original filenode_modules/moment/min/moment-with-locales.js
  4. Line, columnline 6933, column 10
Try it yourself
From node_modules/moment/min/moment-with-locales.js

The slider stops at 40 bytes spread evenly through app.js. For each one, the source map gives the original file, line and column. The box shows the minified code that starts there.

The course read app.js's source map, from esbuild 0.28.2. Lines count from 1, columns from 0.

Adding it all up with source-map-explorer

source-map-explorer follows every byte, not just one. Its README says it "determines which file each byte in your minified code came from." Then it draws a treemap, or prints the totals. Ravi runs it:

Run source-map-explorer on app.jsShell
npx source-map-explorer dist/app.js dist/app.js.map --json --no-border-checks

The first time, without --no-border-checks, it stopped with an error: "Your source map refers to generated column Infinity on line 1, but the source only contains 22459 column(s) on that line." The tool checks that every mapping points inside the file, and one check failed on this map. Its README lists --no-border-checks to turn those checks off. Then it worked:

What source-map-explorer printed, in shortPop out in the code editor (opens in a new tab)JavaScript
// What source-map-explorer printed for app.js, in bytes.const result = { totalBytes: 1208193, mappedBytes: 1204682, unmappedBytes: 3231, eolBytes: 249, sourceMapCommentBytes: 31 }; const traced = (result.mappedBytes / result.totalBytes) * 100;console.log(`Traced to a file: ${traced.toFixed(1)}%`);console.log(`Line breaks and the map's comment: ${result.eolBytes + result.sourceMapCommentBytes} bytes`);
  • Line 2 holds its totals for app.js, in bytes.
  • Lines 4 and 5 print Traced to a file: 99.7%.
  • Line 6 prints Line breaks and the map's comment: 280 bytes.

The other 3,231 bytes had no mapping. Most were esbuild's own helper code at the top of the file, and license comments at the end. For every package, the totals matched the metafile's to within a few hundred bytes.

One warning about its --gzip flag. It packs each run of bytes on its own. On app.js, those small pieces added up to 445,023 bytes. The whole file, packed at once with gzip, was 336,523. The pieces came to about a third more.

Why it matters: a source map works with any bundler, and with files you didn't build. Read compressed sizes per part as rough guides only.

Finding packages bundled twice

Back to that path with node_modules in it twice. The treemap has two boxes for marked, 49.0 KB and 40.3 KB. marked turns Markdown, a simple text format, into HTML. Ravi uses it for the recipes. So where does the second one come from?

npm can say. npm ls prints the packages a project installed, as a tree. Give it a package's name, and it shows every path to that package:

npm ls marked, in the tea list's folderText
tea-list@1.0.0├─┬ marked-react@4.0.0│ └── marked@17.0.6└── marked@18.2.0
  • Line 1 is the project itself, tea-list.
  • Line 2: tea-list depends on marked-react 4.0.0. The chat helper uses it to show its replies.
  • Line 3: marked-react has its own marked, version 17.0.6.
  • Line 4: tea-list's own marked is version 18.2.0.

So two versions are installed, in two folders. Each file gets the copy in its nearest node_modules folder. So marked-react gets 17.0.6, and Ravi's recipes get 18.2.0. npm explain marked, or npm why, tells the same story from the bottom up.

Why didn't npm install one? Each package says which versions it accepts, as a range. Ravi's package.json says "marked": "^18.2.0". marked-react's says "marked": "^17.0.4". The caret, ^, means: the same first number, and this version or a later one.

What a caret range allowsPop out in the code editor (opens in a new tab)JavaScript
// A caret range: the same first number, and this version or a later one.function allows(range, version) {  const [major, minor, patch] = range.slice(1).split(".").map(Number);  const [m, n, p] = version.split(".").map(Number);  return m === major && (n > minor || (n === minor && p >= patch));} console.log(allows("^17.0.4", "17.0.6"));console.log(allows("^17.0.4", "18.2.0"));
  • Lines 2 to 6 are a small version of the caret rule, written for this lesson. npm's real rules handle more cases.
  • Line 5 asks: the same first number, and a later version?
  • Line 8 prints true: 17.0.6 fits ^17.0.4.
  • Line 9 prints false: 18.2.0 doesn't.

No version fits both ranges. So npm installed both, and the bundler packed both.

Real-life analogyA flat-pack shelf with its own screwdriver

You buy a flat-pack shelf. In its box is a small screwdriver. You already own one, a newer one. But the shelf's makers tested with theirs, so it came along. Now the kitchen drawer holds two screwdrivers.

In real life: Your own screwdriver
In web performance: marked 18.2.0, from Ravi's package.json
In real life: The one in the shelf's box
In web performance: marked 17.0.6, inside marked-react
In real life: The kitchen drawer
In web performance: app.js, which now holds both
In real life: Using one screwdriver for both jobs
In web performance: One version that fits both ranges

What the second copy costs

The course built app.js again with one copy of marked, and compared:

The cost of the second copyPop out in the code editor (opens in a new tab)JavaScript
// app.js in bytes: with two copies of marked, and with one.const two = { built: 1208193, sent: 271540 };const one = { built: 1158000, sent: 266614 }; console.log(`As built: ${two.built - one.built} bytes smaller`);console.log(`As sent: ${two.sent - one.sent} bytes smaller`);
  • Lines 2 and 3 hold app.js with two copies and with one, as built and as sent with Brotli.
  • Line 5 prints As built: 50193 bytes smaller.
  • Line 6 prints As sent: 4926 bytes smaller.

Only 4.8 KB sent! The two versions of marked share most of their text. Brotli packs the whole file at once, so the second copy packed small. But the phone still unpacks it. Then it reads every one of those 50,193 bytes, as lesson 22.1 showed. Why it matters: a duplicate costs the phone's CPU more than it costs the network.

Chrome looks for duplicates too. Lighthouse and DevTools' Performance panel have a Duplicated JavaScript insight. It reads each script's source map. Two files count as the same when their paths match after the last node_modules. It keeps the biggest copy, counts the others, and scales them by the file's compression. Its docs ask for one version of each large package in a bundle. Its View Treemap button opens Lighthouse's own treemap of the page's scripts.

Three ways to get one copy

Ask for a version both accept. marked-react asks for marked 17. Ravi's recipes only call marked.parse, and they came out the same with 17. So Ravi asks for ^17.0.6 too, and installs again:

npm ls marked, after the changeText
tea-list@1.0.0├─┬ marked-react@4.0.0│ └── marked@17.0.6 deduped└── marked@17.0.6

Line 3 says deduped. marked-react now uses the copy at the top, on line 4. Only one marked is installed.

Let npm tidy up. Sometimes one copy at the top would fit every range, but npm still installed two. npm favors the newest version a package accepts, so it can put a newer copy deeper in the tree. npm dedupe moves a shared copy up and deletes the extras. Its docs say it never installs anything new, so it couldn't help Ravi. With pnpm, pnpm why marked shows who depends on marked. pnpm dedupe removes older copies from its lock file when a newer one fits.

Force one version. The overrides field in package.json can choose a version for a package deep in the tree:

An override in package.jsonJSON
{  "dependencies": {    "marked": "^18.2.0",    "marked-react": "^4.0.0"  },  "overrides": {    "marked-react": {      "marked": "18.2.0"    }  }}

Lines 6 to 10 tell npm: when marked-react asks for marked, give it 18.2.0. npm only reads overrides in your own project's package.json. Use them with care: marked-react asks for marked 17, not 18, so check that it still works.

Code that ships but never runs

A treemap shows what's in the file. It can't show what runs. For that, Ravi opens DevTools' Coverage panel. Lesson 21.2 used it on CSS. It works on JavaScript too.

Open it from the Command Menu, and pick Per function. Press its reload button. Chrome reloads the page and records which code runs. For JavaScript, it counts functions. A function that was never called counts as unused, with all the code inside it. Per block looks closer: at the parts inside each function, like the two sides of an if.

Real-life analogyA plumber's toolbox

A plumber comes to fix one tap. She carries in her whole toolbox. That evening, she notes which tools came out. Most stayed in the box. Some are for tomorrow's jobs. And one broken tool hasn't left the box in a year.

In real life: The whole toolbox, carried in
In web performance: app.js, downloaded in full
In real life: Tools that came out today
In web performance: Functions that ran during the visit
In real life: Tools for tomorrow's jobs
In web performance: Code for other pages, and for later taps
In real life: The broken tool nobody uses
In web performance: Dead code, like the old quiz

The course took Coverage on the tea list in Chrome 157, on a phone's screen, the way the panel takes it. Here is the total for app.js:

Coverage for app.jsPop out in the code editor (opens in a new tab)JavaScript
// Coverage for app.js once the tea list had loaded, in bytes.const total = 1208193;const used = 805455;const unused = total - used;console.log(`Unused: ${unused} bytes, ${Math.round((unused / total) * 100)}% of app.js`);
  • Line 2 is app.js as built, in bytes.
  • Line 3 is the bytes inside code that ran while the tea list loaded.
  • Line 5 prints Unused: 402738 bytes, 33% of app.js.

A third of the file never ran. DevTools' own Total Bytes column shows 1,204,734 for app.js, a little less. It counts characters, and about 2,300 characters in moment's locales take two or three bytes each.

Coverage gives one number per file. With the source map, DevTools can open app.js's row into its original files. The course split it into the treemap's parts the same way:

The tea list on Asha's phone, and how much of each big part of app.js ran while it loaded. moment: 91.4 KB of 374.5 KB never ran. src/features: 89.3 KB of 364.4 KB never ran. react-dom: 128.9 KB of 205.1 KB never ran. lodash: 52.0 KB of 72.0 KB never ran. marked 18: 9.8 KB of 48.8 KB never ran. marked 17: 12.1 KB of 40.3 KB never ran. In all, 393.3 KB of 1,179.9 KB never ran.What Asha seesThe tea list: Our teas, the Show all 300 teas button, and the first teas, each with a review and its dateCoverage, by partPartNever ranRan (shaded)moment91.4 KBsrc/features89.3 KBreact-dom128.9 KBlodash52.0 KBmarked 189.8 KBmarked 1712.1 KBAll of app.js: 393.3 KB of 1,179.9 KB never ran
A real screen of the page, and Coverage's numbers split by part through the source map. Each bar is as long as the part. The shaded part ran; the rest never did.

Read the rows one at a time:

  • react-dom: 128.9 KB never ran. That's React's code for things this visit didn't need yet, like other kinds of updates and events. Ravi can't remove parts of React.
  • src/features: 89.3 KB never ran. These are the features for the shop's other pages. They run there. Moving them out of this page's file is code splitting, a later lesson in this module.
  • lodash: 52.0 KB never ran. Of old-quiz.jsx's 607 bytes, only 35 ran: its imports, at the top. That one line set lodash up, and nothing ever called it.

Why does the old quiz ship at all? Here is how app.jsx picks a quiz:

How app.jsx picks a quizJavaScript
import { NewQuiz } from "./quiz.jsx";import { OldQuiz } from "./old-quiz.jsx";import { settings } from "./settings.js"; const Quiz = settings.oldQuiz ? OldQuiz : NewQuiz;
  • Lines 1 and 2 import both quizzes.
  • Line 3 imports the shop's switches, which the server writes into the page.
  • Line 5 picks one. settings.oldQuiz is false on every page, so the new quiz always wins.

But the bundler can't know that. The switch is only read when the page runs. So it packs both quizzes, and lodash with the old one.

Why it matters: Coverage sorts code into three piles, and each pile needs a different fix.

  • Later: code that runs after a tap, like the new quiz's button. Keep it.
  • Elsewhere: code for other pages. Keep it, but move it out of this page's file.
  • Never: code no page runs, like the old quiz. Delete it.

Coverage alone can't tell the piles apart. You check each one yourself, as lesson 21.2 did for CSS.

Ran isn't the same as needed

Look at moment's row again. Only 91.4 KB of its 374.5 KB never ran. So three quarters of moment ran. But Asha needs one locale, British English. Yet when the page loaded, moment set up all 139 locales: their month names, their day names, their rules. All that work counts as used.

Real-life analogyLaying every table for one guest

A café expects one guest tonight. The staff lay all 139 places anyway: plates, cups, napkins. At closing time, every place was laid, so every place gets ticked off. Only one was used to eat.

In real life: Laying all 139 places
In web performance: moment setting up every locale as the page loads
In real life: The one guest who comes
In web performance: Asha's locale, British English
In real life: Every place ticked off as laid
In web performance: Coverage counting that setup as used

Why it matters: Coverage measures what ran, not what was worth running. Lighthouse runs the same check, in its Reduce unused JavaScript audit. With a source map, it lists the original files with the most unused code.

Three fixes, measured

Ravi now knows what to change. The first fix is for moment. Browsers already know the words for dates in many languages. Intl.RelativeTimeFormat is built into them, for phrases like "3 days ago". Ravi writes a small function with it:

Dates in any language, without a libraryPop out in the code editor (opens in a new tab)JavaScript
const UNITS = [["year", 31536000], ["month", 2592000], ["week", 604800], ["day", 86400], ["hour", 3600], ["minute", 60]]; function timeAgo(date, now, language) {  const seconds = Math.round((date - now) / 1000);  const [unit, size] = UNITS.find(([, length]) => Math.abs(seconds) >= length) ?? ["second", 1];  return new Intl.RelativeTimeFormat(language, { numeric: "auto" }).format(Math.round(seconds / size), unit);} const now = new Date("2026-11-12T10:00:00Z");console.log(timeAgo(new Date("2026-11-09T10:00:00Z"), now, "en-GB"));console.log(timeAgo(new Date("2026-11-09T10:00:00Z"), now, "fr"));
  • Line 1 lists units of time, each with its length in seconds.
  • Line 4 works out the gap between the two dates, in seconds. Subtracting dates gives milliseconds.
  • Line 5 picks the biggest unit that fits into the gap, or seconds if none does.
  • Line 6 asks Intl.RelativeTimeFormat to say it in language. numeric: "auto" lets it use a word instead of a number, where the language has one.
  • Lines 10 and 11 print 3 days ago and il y a 3 jours, in French.

On the page, the reviews' dates read exactly as before, from "10 months ago" down to "3 months ago". The second fix is the one copy of marked. The third deletes the old quiz: old-quiz.jsx, settings.js and the lines that picked a quiz. lodash goes with them, because nothing else imported it.

Here is what each fix saved on its own:

What each fix saved, sent with BrotliPop out in the code editor (opens in a new tab)JavaScript
// app.js sent with Brotli, in bytes: as it was, and with each fix on its own.const before = 271540;const fixes = { "Intl instead of moment": 207239, "One copy of marked": 266614, "No old quiz": 247583 }; for (const [fix, size] of Object.entries(fixes)) {  console.log(`${fix}: ${((before - size) / 1024).toFixed(1)} KB less`);}
  • Line 2 is app.js as it was, sent with Brotli, in bytes.
  • Line 3 holds app.js built again with each fix alone.
  • Lines 5 to 7 print Intl instead of moment: 62.8 KB less, One copy of marked: 4.8 KB less and No old quiz: 23.4 KB less.

With all three, app.js went from 265.2 KB to 174.1 KB sent. That's about 76 KB under the 250 KB budget. And Asha?

The tea list on Asha's phone before and after Ravi's three fixes. Both screens show the same teas, reviews and dates. app.js went from 265.2 KB to 174.1 KB sent.Before the fixesThe tea list: Our teas, the Show all 300 teas button, and the first teas with their reviewsapp.js: 265.2 KB sentAfter the fixesThe tea list: Our teas, the Show all 300 teas button, and the first teas with their reviewsapp.js: 174.1 KB sent3 fixes
Real screens from the recording. Asha sees the same page. It just costs her phone less JavaScript.

She sees the same teas, the same reviews and the same dates. Coverage changed too. After the fixes, 234.2 KB of app.js never ran: 159.1 KB less than before. But as a share, it went from 33% to 34%. Most of the bytes Ravi removed had run, above all moment's setup for 139 locales. Coverage had counted them as used. So compare bytes, not shares. Compare the two, part by part:

Playground: what ran, part by part
Never ran
  1. app.js, unused393.3 KB of 1,179.9 KB (33%)
  2. moment91.4 KB unused of 374.5 KB (24%)
  3. src/features89.3 KB unused of 364.4 KB (25%)
  4. react-dom128.9 KB unused of 205.1 KB (63%)
  5. lodash52.0 KB unused of 72.0 KB (72%)
  6. marked 189.8 KB unused of 48.8 KB (20%)
  7. marked 1712.1 KB unused of 40.3 KB (30%)
  8. html-entities0.9 KB unused of 34.5 KB (3%)
  9. src/teas.js0.0 KB unused of 16.5 KB (0%)
  10. react5.5 KB unused of 8.3 KB (67%)
  11. marked-react1.6 KB unused of 4.8 KB (33%)
  12. src, the rest0.6 KB unused of 3.7 KB (17%)
  13. scheduler0.8 KB unused of 3.6 KB (22%)
Try it yourself
Before the fixes

Coverage marks the code that ran while the tea list loaded, function by function. The source map splits the rest between the parts of app.js.

Recorded in Chrome 157 on a phone's screen, per function, the way DevTools' Coverage panel takes it. Both runs of each gave the same numbers.

What's left? React, which the page needs. And the shop's other features, which code splitting can move out. Why it matters: three fixes, each found by a different tool, took a third off app.js as sent.

Which tool answers which question?

Each tool in this lesson answers one kind of question. Sort each question by the tool that answers it:

Which tool answers which question?
  • Which package takes the most space in app.js?
  • Which of my files imported lodash?
  • Which original file did byte 300,000 of app.js come from?
  • What's inside a script you didn't build, which ships a source map?
  • Why are there two versions of marked?
  • Which version of marked does marked-react get?
  • Which functions never ran while the tea list loaded?
  • Does the old quiz run at all on this page?
Try it yourself
0 of 8 correct

Sort each question by the tool that answers it.

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

Common mix-ups

  • "A treemap shows what the phone downloads." No. Its boxes are bytes as built. app.js was 1,179.9 KB as built, and 265.2 KB sent.
  • "npm dedupe removes every duplicate." No. It can only share a copy that every range accepts. ^18.2.0 and ^17.0.4 have no version in common.
  • "A duplicate costs its full size in the download." No. The second marked cost 4.8 KB sent. But the phone still read all 50,193 bytes of it.
  • "Unused in Coverage means safe to delete." No. Much of it runs on other pages, or after a tap. Only the old quiz, and lodash with it, was dead.
  • "Used in Coverage means needed." No. moment set up 139 locales as the page loaded, and Coverage counted them all as used. Asha needed one.
  • "The bundler already leaves out code nobody uses." No. It kept the old quiz, because the switch that turns it off is only read when the page runs.

Practice exercises

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

Exercise 1 · Warm-upCounting duplicates like Chrome

A bundle holds three copies of one package. What does this estimate print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// Three copies of one package in a bundle, in bytes as built.
const copies = [41290, 49924, 12000];
// The whole file's size sent, divided by its size as built.
const ratio = 0.22;

const biggest = Math.max(...copies);
const extra = copies.reduce((sum, size) => sum + size, 0) - biggest;
console.log(Math.round(extra * ratio));

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

    Exercise 2 · Warm-upA review from one day ago

    Ravi's timeAgo, with a review written one day before now. What does it print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const UNITS = [["year", 31536000], ["month", 2592000], ["week", 604800], ["day", 86400], ["hour", 3600], ["minute", 60]];
    
    function timeAgo(date, now, language) {
      const seconds = Math.round((date - now) / 1000);
      const [unit, size] = UNITS.find(([, length]) => Math.abs(seconds) >= length) ?? ["second", 1];
      return new Intl.RelativeTimeFormat(language, { numeric: "auto" }).format(Math.round(seconds / size), unit);
    }
    
    const now = new Date("2026-11-12T10:00:00Z");
    console.log(timeAgo(new Date("2026-11-11T10:00:00Z"), now, "en-GB"));

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

      Exercise 3 · PracticeReact DOM, packed alone

      Before the fixes, how many KB is react-dom when its bytes are packed alone?

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

        Exercise 4 · PracticeReact DOM that never ran

        After the fixes, how many KB of react-dom never ran while the tea list loaded?

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

          Exercise 5 · ChallengePer function or per block

          Open the copy of Ravi's tea list from before the fixes. Take Coverage per function, then per block. Which one shows more unused bytes for app.js?

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

            Exercise 6 · ChallengeAn override that follows your own version

            In the override example, Ravi typed 18.2.0 again. npm has a shortcut that reuses the version from his own dependencies. What does he write instead of "18.2.0"?

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

              Check your understanding

              For each question, ask: which tool, and which bytes?

              The bundle analysis quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In a treemap of app.js, what does the area of each box show?

                Choose an answer to see the explanation.

              2. Question 2 of 7Can one copy of 5.0.0 serve both ranges? What does this check print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function allows(range, version) {
                  const [major, minor, patch] = range.slice(1).split(".").map(Number);
                  const [m, n, p] = version.split(".").map(Number);
                  return m === major && (n > minor || (n === minor && p >= patch));
                }
                
                // Can one copy of a package serve both "^4.0.0" and "^5.0.0"?
                console.log(allows("^4.0.0", "5.0.0") && allows("^5.0.0", "5.0.0"));

                Choose an answer to see the explanation.

              3. Question 3 of 7Ravi runs npm dedupe on the tea list. Why are there still two copies of marked?

                Choose an answer to see the explanation.

              4. Question 4 of 7One copy of marked fewer made app.js 50,193 bytes smaller as built, but only 4,926 bytes smaller as sent. Why so little?

                Choose an answer to see the explanation.

              5. Question 5 of 7Coverage says two thirds of react-dom never ran. What should Ravi do?

                Choose an answer to see the explanation.

              6. Question 6 of 7What does this Coverage sum for the fixed app.js print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Coverage for app.js after Ravi's fixes, in bytes.
                const total = 698570;
                const used = 458735;
                console.log(Math.round(((total - used) / total) * 100));

                Choose an answer to see the explanation.

              7. Question 7 of 7What does a source map let source-map-explorer do with a minified app.js?

                Choose an answer to see the explanation.

              Key takeaways

              • A bundler can describe what it built. esbuild writes a metafile; webpack writes stats. A treemap draws them.
              • Treemap boxes are bytes as built. Compressed sizes per part are estimates. Check the budget on the whole file.
              • A source map ties every run of bytes back to a file, a line and a column, for any bundler.
              • npm ls shows why a package is in twice. Ranges with no version in common mean two copies.
              • A duplicate can cost little to download, and still cost the phone time to read.
              • Coverage shows what ran. Sort what didn't into later, elsewhere and never. Only never is safe to delete.
              • Ran isn't needed: moment set up 139 locales for one visitor.

              Remember the one-liner.
              Open the bundle, find where each byte came from, then cut the biggest, the doubled and the never-run parts first.

              Sources

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

              Coming next: Tree shaking. The bundler kept the old quiz because it couldn't prove nobody used it. Next, see what it can drop on its own, and what stops it.

              CompleteFrontend Clear concepts. Working examples.