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.
- 01Read a treemap of your bundleGet your bundler's stats or metafile, find the biggest parts, and know which size each box shows.
- 02Trace 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.
- 03Sort 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 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.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.jsandlodash: 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.
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
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:
npx esbuild src/app.jsx --bundle --minify --outfile=dist/app.js \ --metafile=meta.json --analyze- Line 1 is the build: bundle
src/app.jsxand everything it imports, minify it, and writedist/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.jsonwrites a file of facts about the build, called the metafile.--analyzeprints a short report from it.
Here are the first lines of the report for Ravi's app.js:
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 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.jsand3.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.jsximportssrc/old-quiz.jsx.src/old-quiz.jsximportsnode_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.
| If you build with | Use | What it reads |
|---|---|---|
| webpack | webpack-bundle-analyzer, a plugin. | webpack's stats, and the built files. |
| Vite or Rollup | rollup-plugin-visualizer, a plugin. | Rollup's list of the modules in each chunk. |
| esbuild | Its Bundle Size Analyzer page, or --analyze for text. | The metafile that --metafile writes. |
| Next.js | next analyze, from the lesson Shrinking the JavaScript bundle. | The app, compiled the way a production build would be. |
| Any of them, with source maps | source-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:
- Version2.31.0
- As built374.5 KB, 31.7% of app.js
- Compressed alone62.6 KB
- Files1
- Brought in bysrc/app.jsx → src/reviews.jsx
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.
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:
fineLocale("az",{months:"yanvar_fevral_mWhose 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.
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:
{ "version": 3, "sources": ["../node_modules/react/cjs/react.production.js", "../node_modules/react/index.js", "..."], "sourcesContent": ["...", "...", "..."], "mappings": "...", "names": ["..."]}- Line 2:
versionis always 3. - Line 3:
sourceslists the original files. This map lists 213. - Line 4:
sourcesContentholds the text of each file, so tools can show it. It's optional. - Line 5:
mappingsis 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:
nameslists 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:
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:
fineLocale("az",{months:"yanvar_fevral_m
- Byte of app.js316,302 of 1,208,193
- Partmoment
- Original filenode_modules/moment/min/moment-with-locales.js
- Line, columnline 6933, column 10
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.
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:
npx source-map-explorer dist/app.js dist/app.js.map --json --no-border-checksThe 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 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:
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.
// 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.
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:
// 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:
tea-list@1.0.0├─┬ marked-react@4.0.0│ └── marked@17.0.6 deduped└── marked@17.0.6Line 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:
{ "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.
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.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:
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:
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.oldQuizis 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.
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:
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.RelativeTimeFormatto say it inlanguage.numeric: "auto"lets it use a word instead of a number, where the language has one. - Lines 10 and 11 print
3 days agoandil 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:
// 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 lessandNo 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?
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:
- app.js, unused393.3 KB of 1,179.9 KB (33%)
- moment91.4 KB unused of 374.5 KB (24%)
- src/features89.3 KB unused of 364.4 KB (25%)
- react-dom128.9 KB unused of 205.1 KB (63%)
- lodash52.0 KB unused of 72.0 KB (72%)
- marked 189.8 KB unused of 48.8 KB (20%)
- marked 1712.1 KB unused of 40.3 KB (30%)
- html-entities0.9 KB unused of 34.5 KB (3%)
- src/teas.js0.0 KB unused of 16.5 KB (0%)
- react5.5 KB unused of 8.3 KB (67%)
- marked-react1.6 KB unused of 4.8 KB (33%)
- src, the rest0.6 KB unused of 3.7 KB (17%)
- scheduler0.8 KB unused of 3.6 KB (22%)
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.
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 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?
Sort each question by the tool that answers it.
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.0and^17.0.4have 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.
A bundle holds three copies of one package. What does this estimate print?
// 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));11724. Chrome's insight keeps the biggest copy, 49,924 bytes, and counts the others: 53,290 bytes as built. The ratio turns that into an estimate of the bytes sent.
Ravi's timeAgo, with a review written one day before now. What does it print?
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"));yesterday. With numeric: "auto", Intl.RelativeTimeFormat uses the word English has for one day back, instead of "1 day ago".
Before the fixes, how many KB is react-dom when its bytes are packed alone?
54.9 KB. That's react-dom's own bytes from app.js, packed on their own with Brotli. It's an estimate: in the real file, Brotli packs everything together.
After the fixes, how many KB of react-dom never ran while the tea list loaded?
128.5 KB, about the same as before the fixes. Ravi didn't change React. Its unused code is for updates and events this visit didn't need.
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?
Per block. It found 39% of app.js unused in the course's recording, against 33% per function. A function that ran can still hold branches that didn't.
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"?
{
"dependencies": {
"marked": "^18.2.0",
"marked-react": "^4.0.0"
},
"overrides": {
"marked-react": {
"marked": "$marked"
}
}
}$marked. A value that starts with $ means: use the same version as my own dependency on that package. Change marked once, and the override follows.
Check your understanding
For each question, ask: which tool, and which bytes?
Question 1 of 7In a treemap of app.js, what does the area of each box show?
Choose an answer to see the explanation.
Question 2 of 7Can one copy of 5.0.0 serve both ranges? What does this check print?
Read the code, then predictfunction 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.
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.
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.
Question 5 of 7Coverage says two thirds of react-dom never ran. What should Ravi do?
Choose an answer to see the explanation.
Question 6 of 7What does this Coverage sum for the fixed app.js print?
Read the code, then predict// 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.
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 lsshows 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:
- esbuild API: Metafile and Analyze, Evan Wallace.
- esbuild Bundle Size Analyzer, Evan Wallace.
- webpack-bundle-analyzer 5.4.2: README, size definitions, the webpack contributors.
- rollup-plugin-visualizer 7.1.3: README, Denis Bardadym.
- ECMA-426: Source map format specification, Ecma TC39-TG4.
- source-map-explorer 2.5.3: README, Dan Vanderkam and contributors.
- source-map-explorer 2.5.3: explore.ts, how --gzip sizes each range, Dan Vanderkam and contributors.
- npm ls, npm.
- npm explain, npm.
- node-semver: caret ranges, npm.
- npm dedupe, npm.
- package.json: overrides, npm.
- pnpm why, pnpm.
- pnpm dedupe, pnpm.
- Duplicated JavaScript insight, Chrome for Developers.
- Chrome DevTools: ScriptDuplication.ts, how duplicates are counted, the Chromium authors.
- Coverage: Find unused JavaScript and CSS, Chrome for Developers.
- Chrome DevTools: CPUProfilerModel.ts, how Coverage starts, the Chromium authors.
- JavaScript code coverage, V8.
- Reduce unused JavaScript, Chrome for Developers.
- Lighthouse 13.6.0: unused-javascript.js, Google Chrome.
- Moment.js docs: Using Moment, the Moment.js team.
- Intl.RelativeTimeFormat, MDN.
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.