Minification and build output
See what a minifier takes out of your JavaScript, choose a build target, ship source maps without sharing your code, and check the files your build writes.
- 01Read what a minifier didTell spaces, short names and shorter syntax apart, and know what each one saves once Brotli has packed the file.
- 02Choose a build targetSee which syntax a target rewrites into older, longer code, and what that costs the tea list.
- 03Ship source maps safelyRead real errors with a source map, keep the map away from everyone else, and check the files the build writes.
Ravi opens dist/app.js
The tea list at Leaf and Kettle is in good shape. Since lesson 22.4, it loads in pieces. Since lesson 22.5, it carries no package it doesn't need. Its first load sends 111.1 KB, well under the 250 KB budget from lesson 22.1.
One evening, Ravi opens dist/app.js. That's the main file his build writes. He wants to find his own code in it. He can't. The whole file is squeezed onto 9 lines of code. The longest line holds 145,347 characters.
Ravi has typed --minify into his build since lesson 22.2. He never looked at what it does. So now he has two questions. What did the build do to his code? And did it do that job well?
He starts by building the tea list again, without --minify. Then he puts the two files side by side:
function push(heap, node). In Ravi's build, the same function is function Rf(l,t), on the second row.The top file is easy to read. It has 14,095 lines, with spaces, comments and long names. Line 12 starts a function called push. It's React's code, not Ravi's: the scheduler, which decides when React does its work.
The bottom file is Ravi's real build. The same function is there, as Rf. Its inputs heap and node are now l and t. The spaces and line breaks are gone.
This is minification. To minify code means to rewrite it shorter, so it does exactly the same thing with fewer characters. A tool that does it is a minifier.
Does it matter? The course loaded both builds on Asha's phone, three times each. "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.
// The tea list's first load on Asha's phone, built two ways.// KB as built, KB sent with Brotli, and when it could answer a tap, in ms.const unminified = [924.1, 146.8, 2712];const minified = [412.9, 111.1, 2257]; console.log(`${(unminified[0] - minified[0]).toFixed(1)} KB less as built`);console.log(`${(unminified[1] - minified[1]).toFixed(1)} KB less to send`);console.log(`Ready ${unminified[2] - minified[2]} ms sooner`);- Lines 1 and 2 are comments. A KB is 1,024 bytes. As built means the file the bundler wrote. Sent with Brotli means the bytes that travel, after the server packs the file.
- To answer a tap is as in lesson 22.1. It's the later of two moments: the teas appear, and the last long task ends. A long task keeps the phone busy for more than 50 ms.
- Line 3 is the unminified build. Line 4 is Ravi's build, minified.
- Lines 6 to 8 print
511.2 KB less as built,35.7 KB less to sendandReady 455 ms sooner.
Minifying cut the files to less than half. The bytes that travel fell by about a quarter, because Brotli packs files well either way. Still, the phone could answer a tap 455 ms sooner. Why it matters: minifying is one flag in the build, and it buys real time on a slow phone.
You look up a soup recipe online. First comes a long story about the cook's grandmother. Then the steps, with big gaps and photos between them. A chef's own card says the same in four short lines: "Onion, chop. Fry 5 min. Add 2 tbsp stock." The soup comes out the same.
- In real life: The cook's long story before the steps
- In web performance: Comments in the code
- In real life: Big gaps and pictures between the steps
- In web performance: Spaces, tabs and line breaks
- In real life: "tbsp" for tablespoon
- In web performance: Short names, like
lforheap - In real life: The same soup at the end
- In web performance: The same page, doing the same things
A minifier rewrites code shorter without changing what it does. It removes spaces and comments, shortens names, and swaps in shorter syntax. Around it sit three more choices. The build target says how new that syntax may be. A source map leads from the short code back to yours. And you check what the build wrote before anyone downloads it.
The lesson follows Ravi, one question at a time:
- What exactly did
--minifytake out? Spaces, names and syntax. - Is esbuild's minifier any good? Four minifiers on one tea list.
- How new may the syntax be? Build targets.
- How do you read an error from minified code? Source maps in production.
- What else should he check? Reading what the build wrote.
What minification takes out
A whole app is too much to read. So Ravi tries the minifier on one small file he knows well: time-ago.js. It's the helper from lesson 22.2 that writes "3 months ago" under each review.
const UNITS = [["year", 31536000], ["month", 2592000], ["week", 604800], ["day", 86400], ["hour", 3600], ["minute", 60]];
export 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);
}- Line 1 lists units of time, each with its length in seconds.
- Line 4 works out how many seconds lie between a review's date and now.
- Line 5 picks the biggest unit that fits. If none fits, it falls back to seconds.
- Line 6 turns the number into words, like "3 months ago", in the visitor's language.
The file is 430 bytes. esbuild's docs say --minify is three switches in one:
--minify-whitespaceremoves spaces, tabs, line breaks and comments.--minify-identifiersshortens names. A name in code is called an identifier.--minify-syntaxrewrites code into shorter forms that mean the same.
Here is time-ago.js with only the names shortened. The layout stays, so it's easy to compare:
const u = [["year", 31536e3], ["month", 2592e3], ["week", 604800], ["day", 86400], ["hour", 3600], ["minute", 60]];function s(n, e, o) { const t = Math.round((n - e) / 1e3); const [a, r] = u.find(([, i]) => Math.abs(t) >= i) ?? ["second", 1]; return new Intl.RelativeTimeFormat(o, { numeric: "auto" }).format(Math.round(t / r), a);}export { s as timeAgo};- Line 1:
UNITSis nowu. - Line 2: the function is now
s. Its three inputs aren,eando. - Lines 3 and 4:
secondsist,unitisa,sizeisr, andlengthisi. - Line 8 keeps the name that other files use:
s as timeAgo. Other files importtimeAgo, so that name has to stay.
Some words didn't change at all: find, round, numeric and "second". They're property names and text. Code anywhere can look them up by name, so a minifier leaves them alone. esbuild can rename properties too, but its docs warn that this "can break your code in subtle ways".
Now all three switches, which is what --minify does:
const u=[["year",31536e3],["month",2592e3],["week",604800],["day",86400],["hour",3600],["minute",60]];function s(n,e,o){const t=Math.round((n-e)/1e3),[a,r]=u.find(([,i])=>Math.abs(t)>=i)??["second",1];return new Intl.RelativeTimeFormat(o,{numeric:"auto"}).format(Math.round(t/r),a)}export{s as timeAgo};- It's one line now. Every space that wasn't needed is gone.
- The syntax got shorter too.
1000became1e3, the same number written in fewer characters. And the twoconstlines became one, joined by a comma. - It's 304 bytes, down from 430.
Comments go too. time-ago.js has none, but the unminified tea list is full of them. The only ones left are five license comments: notes, marked with @license, that say who wrote a package and under what license. The reading section comes back to them.
Try each switch on its own and together. The rows show the whole tea list built the same way:
const u=[["year",31536e3],["month",2592e3],["week",604800],["day",86400],["hour",3600],["minute",60]];function s(n,e,o){const t=Math.round((n-e)/1e3),[a,r]=u.find(([,i])=>Math.abs(t)>=i)??["second",1];return new Intl.RelativeTimeFormat(o,{numeric:"auto"}).format(Math.round(t/r),a)}export{s as timeAgo};
- time-ago.js304 bytes, from 430
- The tea list's first load, as built412.9 KB
- With gzip129.1 KB
- With Brotli, as Asha gets it111.1 KB
- Lighthouse's Minify JavaScript auditlists no file
Press a switch to turn it on or off. All three on is --minify. The box shows what esbuild made of time-ago.js. The rows show the tea list's first load built the same way.
Which switch saves the most?
The course built the tea list with one more switch each time. Here is its first load after each step:
// The tea list's first load, in KB: as built, and sent with Brotli.const steps = [ ["no minifying", 924.1, 146.8], ["+ whitespace", 701.2, 134.5], ["+ names", 425.7, 111.9], ["+ syntax (--minify)", 412.9, 111.1],];for (const [step, built, sent] of steps) { console.log(`${step}: ${Math.round((built / 924.1) * 100)}% as built, ${Math.round((sent / 146.8) * 100)}% sent`);}- Lines 3 to 6 hold each step: its name, its size as built, and its size sent with Brotli.
- Line 9 prints each as a share of the unminified build. Line 6's step prints
+ syntax (--minify): 45% as built, 76% sent.
Removing whitespace cut a quarter of the file as built. But it saved only 8% of the bytes that travel. Brotli had already found those runs of spaces, and packed them small. Shorter names saved much more of what travels, because each short name is short every time it appears. Shorter syntax added a little on top.
Why it matters: judge a minifier by the bytes it sends, not by the size of the file on disk.
Does Asha's phone read minified code faster?
Fewer characters sound like less work for the phone, too. Before a browser runs JavaScript, it parses it: it reads the text and works out its structure. Then it compiles it into steps the engine can run.
The course traced every load and added up Chrome's work on the tea list's code. Unminified, it took 214 to 231 ms across the three runs. Minified, it took 219 to 239 ms. The ranges overlap. On this phone, the minified code wasn't measurably quicker to parse and run. The 455 ms came from the download.
Why it matters: on a slow network, minifying pays off in the bytes that travel.
Four minifiers, one tea list
esbuild isn't the only minifier. Its own docs say it isn't "the optimal JavaScript minifier in all cases". They name Terser as a tool with more advanced tricks. Other tools use other minifiers: Vite's docs say it uses one called Oxc by default.
So the course took the unminified tea list and minified its 8 files with four tools: esbuild, Terser, SWC and Oxc. Each ran with its own defaults. Terser ran three ways:
- names only;
- with its compress step once. Compress is Terser's name for rewriting the syntax;
- with compress three times. Each run is called a pass.
Read the table across: the tool, what the tea list's first load sends with Brotli, the time it took on all 8 files, and the license comments it kept. Each time is the middle of three runs.
| Minifier | First load, sent with Brotli | Time for 8 files | License comments kept |
|---|---|---|---|
| esbuild | 111.4 KB | 77 ms | 5 of 5 |
| Terser, names only | 110.6 KB | 773 ms | 5 of 5 |
| Terser, 1 pass | 109.3 KB | 1,378 ms | 5 of 5 |
| Terser, 3 passes | 109.1 KB | 2,539 ms | 5 of 5 |
| SWC | 108.2 KB | 165 ms | 0 of 5 |
| Oxc | 109.7 KB | 39 ms | 0 of 5 |
The smallest result, SWC's, sent 3.2 KB less than esbuild's. That's under 3%. The times were far apart: Terser's three passes took about 2.5 seconds, and Oxc took 39 ms.
You pack the car for a holiday. Then you take it all out and pack it again, a bit tighter. One more small bag fits. A third go fits almost nothing more. Meanwhile, everyone waits in the car.
- In real life: One go at packing the boot
- In web performance: One compress pass
- In real life: The extra small bag that fits
- In web performance: The bytes a pass saves: 176 here
- In real life: Everyone waiting in the car
- In web performance: The build taking longer: about 1.2 seconds
Terser's docs say the same about passes: more than one "leads to further compressed code" in some cases, but "more passes will take more time". On the tea list, three passes saved 176 more bytes than one.
One more difference hides in the last column. esbuild and Terser kept React's five license comments. SWC and Oxc dropped all of them. SWC's docs say its compress step "will remove all comments". Oxc's own type file says its default for them is "none" when minifying. If your packages' licenses ask you to keep their notices, check what your minifier does.
Compare the tools yourself. Every one of them still drew all 8 teas in Chrome:
- The tea list's first load, as built414.0 KB
- With Brotli, as Asha gets it111.4 KB
- Time to minify all 8 files77 ms, the middle of 3 runs
- License comments kept5 of 5
- In Chromedrew all 8 teas, no errors
Pick a minifier. Each one minified the 8 files of the unminified build, one file at a time. The rows show the tea list's first load, the time, the license comments it kept, and whether the page still worked.
Why it matters: the minifier your bundler has is close to the best. Choose on speed and on what it keeps, not on a few bytes.
Build targets: how new the syntax may be
Ravi's build has said --target=es2022 since lesson 22.1. Lesson 22.5 showed what a target doesn't do. esbuild's docs say it's "only concerned with syntax features, not APIs". It never adds polyfills. So what does it do?
JavaScript's versions are named by year, like ES2020: the year the standard was approved. ES2020 added ??, the nullish coalescing operator. a ?? b gives a, unless a is null or undefined. Then it gives b. time-ago.js uses it on line 5.
A build target names the oldest JavaScript the output must run in. It can be a version, like es2015, or a list of browsers. When a piece of syntax is newer than the target, esbuild rewrites it in older syntax. Here is time-ago.js built for es2015, not minified, so you can read it:
const UNITS = [["year", 31536e3], ["month", 2592e3], ["week", 604800], ["day", 86400], ["hour", 3600], ["minute", 60]];function timeAgo(date, now, language) { var _a; const seconds = Math.round((date - now) / 1e3); const [unit, size] = (_a = UNITS.find(([, length]) => Math.abs(seconds) >= length)) != null ? _a : ["second", 1]; return new Intl.RelativeTimeFormat(language, { numeric: "auto" }).format(Math.round(seconds / size), unit);}export { timeAgo};- Line 3 is new. It makes a spare variable,
_a. - Line 5 is the old line 5, rewritten. It keeps what
findgives back in_a. If_aisn'tnullorundefined, it uses_a. Otherwise, it uses["second", 1].
That's exactly what ?? does, in older words. Minified, the es2015 version is 321 bytes, against 304 for es2020. An old target also stops the minifier from using short new forms. esbuild's docs give an example: a === undefined || a === null ? 1 : a could become a ?? 1, but not for an old target.
Your recipe says "microwave for 2 minutes". Your friend's kitchen has no microwave. So you rewrite that step: "heat in a pan on a low flame, stirring, for 6 minutes". The dish is the same. The recipe is longer.
- In real life: The kitchen you write the recipe for
- In web performance: The build target, like es2015
- In real life: "Microwave for 2 minutes"
- In web performance:
a ?? b - In real life: The longer steps with a pan
- In web performance:
(_a = a) != null ? _a : b - In real life: Kitchens that all have a microwave now
- In web performance: Browsers that run
??
The tea list also uses ?., the optional chaining operator, from the same year. a?.b reads b only if a isn't null or undefined. Here is when each operator arrived in browsers:
The course built the whole tea list for four targets. esnext means the newest JavaScript: rewrite nothing. Pick a target to see what changed:
const u=[["year",31536e3],["month",2592e3],["week",604800],["day",86400],["hour",3600],["minute",60]];function s(n,e,o){const t=Math.round((n-e)/1e3),[a,r]=u.find(([,i])=>Math.abs(t)>=i)??["second",1];return new Intl.RelativeTimeFormat(o,{numeric:"auto"}).format(Math.round(t/r),a)}export{s as timeAgo};
- time-ago.js, minified304 bytes
- The tea list's first load, as built412.9 KB
- With Brotli, as Asha gets it111.1 KB
- ?? and ?. rewritten0 and 0
- Helpers esbuild addednone
Pick a target. The box shows time-ago.js built for it, minified. The rows show the tea list's first load, what esbuild rewrote, and the helpers it added.
For es2015, esbuild rewrote 90 ?? and 31 ?. in the first load. It also added helpers: small functions it writes once and calls from the rewritten code. One is __async, for async functions. Still, the first load sent only 1.1 KB more with Brotli. Most of the tea list, React included, is written in older syntax already. And es2022 and esnext built the very same files: nothing in the tea list is newer than ES2022.
Why it matters: every visitor gets the longer code, even on a new phone. Choose the target from your visitors' browsers, as lesson 22.5 did with browser lists.
Source maps in production
A week later, Meera adds a review that came by post. Ben wrote it, about the mint tea. She types it into the shop's list of reviews by hand. She writes the date the British way: 28/10/2026. Ravi builds and ships. That evening, the tea list shows nothing but the shop's name.
What went wrong? JavaScript can't read that date:
// Ben's review, as Meera typed it into reviews-data.js:// { tea: "Mint tea", by: "Ben", text: "Lovely and fresh.", date: "28/10/2026" }const date = new Date("28/10/2026");console.log(String(date));console.log(Math.round((date - new Date()) / 1000));- Lines 1 and 2 are comments: Ben's review, as Meera typed it.
- Line 3 makes a date from her text. It isn't a format JavaScript knows.
- Line 4 prints
Invalid Date. Line 5 does the sum from line 4 of time-ago.js, and printsNaN: not a number.
With NaN seconds, time-ago.js reaches line 6. There, format refuses NaN and throws an error. React stops drawing the tea list.
But how would Ravi find out? Asha's phone doesn't tell him. An error tracker does: a small script in the page that catches errors and sends them to a server. Here is a tiny one, which the course put in the page:
<script>
window.caught = [];
addEventListener("error", (event) => caught.push(event.error.stack));
</script>- Line 2 makes a list for the errors.
- Line 3 listens for any error that nothing else caught. It keeps the error's stack: the functions that were running, the innermost first. Each comes with a file, a line and a column.
Here is the start of what it caught when Chrome loaded the tea list:
RangeError: Value need to be finite number for Intl.RelativeTimeFormat.prototype.format()
at RelativeTimeFormat.format (<anonymous>)
at St (teas-OIK5BIF3.js:1:111616)
at teas-OIK5BIF3.js:1:111845
at Array.map (<anonymous>)
at Lt (teas-OIK5BIF3.js:1:111761)- Line 1 is the error message, from Chrome.
- Line 2 is the built-in function that threw it.
- Line 3 is where it was called from: a function called
St, in the fileteas-OIK5BIF3.js, line 1, column 111,616.
St? Ravi never wrote a function called St. The minifier named it. And column 111,616 of a squeezed file tells him nothing. Browsers won't help here, either. esbuild's docs say that the stack your code reads "will still give the unmapped stack trace". So a tracker always gets the minified stack.
Lesson 22.2 met the tool that leads back: a source map. It's the card that comes with the box of chocolates, saying which spot holds which sweet. Ravi built with source maps and kept them. Then he wrote a short script:
import { readFileSync } from "node:fs";import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"; const [, file, line, column] = process.argv[2].match(/([\w-]+\.js):(\d+):(\d+)/);const map = new TraceMap(readFileSync(`dist/${file}.map`, "utf8"));const found = originalPositionFor(map, { line: Number(line), column: Number(column) - 1 });console.log(`${found.source}:${found.line}:${found.column + 1}`);- Line 2 brings in trace-mapping, a small library that reads source maps.
- Line 4 splits a place like the one on line 3 of the stack into its file, line and column.
- Line 5 reads that file's map from the dist folder.
- Line 6 asks the map where that place came from. Stacks count columns from 1, and maps from 0, so it takes 1 away.
- Line 7 prints the original file, line and column.
$ node read-stack.mjs teas-OIK5BIF3.js:1:111616
../src/time-ago.js:6:69Line 2 is the answer: ../src/time-ago.js, line 6, column 69. The ../ means one folder up from dist/. Column 69 is the word format, on the line that turns seconds into words. The map turned St back into Ravi's own file:
format call on line 6 of time-ago.js.Why it matters: without the map, a real visitor's error is a column number. With it, it's a line of your code.
Who can see a source map?
How Ravi ships the maps decides who can read his code. The course built the tea list five ways and loaded each one in Chrome.
First, the default: linked maps. esbuild writes app.js.map next to app.js. It ends app.js with a comment that points to it: //# sourceMappingURL=app.js.map. The source map standard, ECMA-426, defines that comment.
Does Asha's phone download the map? No. In every load, the course's server saw no request for a map. Chrome only told its debugger where the map was. Chrome's docs say DevTools loads the maps when you open it.
But anyone can open DevTools. Or just ask for the file. The course did, from the page itself. It got the whole map back: 1,001.9 KB. Inside, a field called sourcesContent held every original file, time-ago.js included. That code was 64.9% of the maps' bytes. Sentry, an error tracker, warns about this in its docs. Public maps can cause "your source code to be leaked".
You leave a spare key so a friend can water your plants. Under the doormat, your friend finds it. So does anyone else who looks. Left with a neighbour you trust, only the people you choose can use it.
- In real life: The spare key
- In web performance: The source map
- In real life: Under the doormat
- In web performance: On the server, named by the comment
- In real life: A neighbour you trust
- In web performance: The error tracker
- In real life: Your friend, watering the plants
- In web performance: Ravi, reading a stack
The other way is hidden maps. esbuild calls it --sourcemap=external: it writes the maps, but no comment. Ravi keeps the maps off the server. He gives them to his error tracker, and keeps a copy for read-stack.mjs. Vite calls the same thing 'hidden'. The course's server answered 404, "not found", for /app.js.map.
Three more ways are worth knowing:
--sources-content=falsekeeps the maps, but leaves the code out. The maps shrank from 1,513.5 KB to 523.0 KB. esbuild's docs say the code isn't needed if the maps are only for stack traces. The file names still show.- Inline maps go inside app.js itself. esbuild's docs say "you usually do not want to ship" them. Here, the first load grew to 494.8 KB with Brotli, for every visitor.
- A server can name the map in a header instead of a comment. The standard says the header wins if both are there.
HTTP/2 200
content-type: text/javascript
sourcemap: app.js.mapLine 1 is the status. Line 2 says the body is JavaScript. Line 3 names the map. Chrome's debugger picked it up for every file, just as it did with the comment.
Read each row across: the way, how app.js ends, and who can read Ravi's code.
| The way | The end of app.js | Who can read Ravi's code |
|---|---|---|
Linked: --sourcemap | //# sourceMappingURL=app.js.map | Anyone. The map is on the server, with every original file inside it. |
Hidden: --sourcemap=external, maps kept off the server | Nothing. The file ends with its license comments. | Only Ravi, and the error tracker he gives the maps to. |
Linked, with --sources-content=false | //# sourceMappingURL=app.js.map | Nobody. The map holds file names and places, not the code. |
In a header: SourceMap: app.js.map | Nothing. The server names the map instead. | Anyone, if the server hands out the map. |
Inline: --sourcemap=inline | The whole map, packed into the comment. | Anyone. And every visitor downloads it with the code. |
- The last line of app.js//# sourceMappingURL=app.js.map
- The tea list's first load, with Brotli111.3 KB
- Its map files, as built1,513.5 KB
- Map Chrome's debugger was told aboutapp.js.map
- Maps the page asked for0
- Anyone fetching /app.js.map getsthe map: 1,001.9 KB
- Ravi's code inside the mapyes, every file
Pick a way. The rows show how app.js ends, what the first load sends, and the maps' size. Then what Chrome saw, what the page asked for, and what anyone gets who asks for the map.
And if Ravi wants DevTools to use a hidden map? Chrome's docs show how. Open the file in the Sources panel, right-click, and pick "Add source map". The map can stay on his own computer.
Hiding the packages' lines
Ben's stack had 10 lines under the message, and 5 of them were React's. ECMA-426 has a field for that, called ignoreList. It lists the files in a map that are someone else's code. The standard says "such as framework code". DevTools then hides those lines from stacks in the Console. esbuild doesn't write the field, so Ravi adds it himself:
import { readFileSync, writeFileSync } from "node:fs"; for (const path of process.argv.slice(2)) { const map = JSON.parse(readFileSync(path, "utf8")); map.ignoreList = map.sources.flatMap((source, index) => (source.includes("node_modules/") ? [index] : [])); writeFileSync(path, JSON.stringify(map)); console.log(`${path}: ${map.ignoreList.length} of ${map.sources.length} files ignored`);}- Line 3 goes through each map named on the command line.
- Line 5 lists the place, in
sources, of every file undernode_modules/. - Lines 6 and 7 save the map and print how many files it marked.
$ node mark-ignored.mjs dist/*.map
dist/app.js.map: 6 of 7 files ignored
dist/chunk-EUPKG5FS.js.map: 4 of 4 files ignored
dist/teas-MBGW5BGL.js.map: 22 of 75 files ignored
dist/chunk-O4EVOUHR.js.map: 14 of 14 files ignoredIn app.js's map, 6 of the 7 files are packages. In the tea list's own chunk, 22 of 75 are. Vite and Rollup have a setting that does this for you.
Reading what the build wrote
A build is a program that writes programs. Like any program, it can do something you didn't expect. So the last habit is the simplest one: read what it wrote.
A café owner types a new menu on her laptop. It looks perfect. Then the printer turns "£4.50" into "£450". She only catches it because she reads the printed copy before it goes on the tables.
- In real life: The menu on the laptop
- In web performance: src/, the code Ravi writes
- In real life: The printed copy
- In web performance: dist/, the files the build writes
- In real life: "£450" on the printed menu
- In web performance: React's development build in dist/
- In real life: Reading it before it goes on the tables
- In web performance: Checking dist/ before you deploy
Start with the list of files, and what each one sends:
app.js 209.3 KB built 56.0 KB sent the tea list loads it at once
review-editor-4DU6BMSB.js 199.2 KB built 50.5 KB sent
teas-K7BTC3Z3.js 191.9 KB built 50.9 KB sent the tea list loads it at once
chunk-MVGAD34A.js 2.4 KB built 1.0 KB sent the tea list loads it at once
cart-DB4LJASR.js 90.9 KB built 24.7 KB sent
checkout-HJ3JFVQ3.js 91.9 KB built 25.0 KB sent
account-SF7APBD2.js 88.5 KB built 24.0 KB sent
chunk-RJHX4K5A.js 9.4 KB built 3.1 KB sent the tea list loads it at onceThere are 8 files. The four the tea list loads at once send 111.1 KB. The rest wait for their pages, as lesson 22.4 set up.
Then open a file. You don't have to read squeezed code by eye. Chrome's docs say the Sources panel lays a minified file out on many lines for you, though "it contains all the code in a single line". Or build once without --minify, as Ravi did. Read this table across: what to check, how, and what Ravi's build showed.
| What to check | How | What Ravi's build showed |
|---|---|---|
| Is it minified? | Open the file. Minified code fills a few very long lines. | app.js holds 9 lines of code. The longest has 145,347 characters. |
| Which React went in? | Build once without --minify, and read the file names in the comments. | The production files. Without define, the development files. |
| What's at the end of each file? | Read the last lines. | 5 license comments in the first load. A map comment only with linked maps. |
| What does each file send? | Pack each file with Brotli, as the server does. | The 4 files of the first load send 111.1 KB. |
The wrong React
When Ravi built without --minify for the first picture, he also passed --define:process.env.NODE_ENV="production". That tells React which of its builds to use. Without it, esbuild decides. Its docs say it picks "production" only "if all minification options are enabled", and "development" otherwise.
The course built it that way once. The first load held react-dom-client.development.js, React's version for building apps. It sent 209.6 KB with Brotli instead of 146.8 KB. Why it matters: only reading the output shows which React went in.
The comments that stay
esbuild moves license comments to the end of each file. Its docs say it keeps them "since that follows the intent of the original authors of the code". In the tea list's first load, they were 5 comments and 1,571 bytes as built. A small price, and a fair one.
A check that only counts spaces
Lighthouse has an audit that checks for minifying, called Minify JavaScript. The course ran the audit's own counting code on each build. It counts the characters that aren't spaces or comments.
- For the unminified build, it listed app.js and the tea list's chunk.
- For the build with whitespace removed and nothing else, it listed nothing.
Yet that whitespace-only build sent 23.4 KB more with Brotli than --minify. Its long names were all still there. Why it matters: a passing audit means the spaces are gone. It doesn't mean the job is done.
Send it, keep it, or leave it out?
Here are things a build can make. Sort each one by where it belongs:
- app.js, minified
- app.js.map, with Ravi's code inside
- A source map packed inside app.js
- React's license comments, at the end of the file
- React's development build
- An unminified build, made to read the code
Sort each thing by where it belongs.
Common mix-ups
- "Minified code runs faster on the phone." No. On Asha's phone, Chrome's work on the code was about the same both ways. The 455 ms came from the download.
- "A minifier renames everything." No. Exported names, property names and text stay, so other code still finds them. time-ago.js kept
timeAgo. - "A better minifier will halve the file again." No. The four tools landed within 3.2 KB of each other with Brotli.
- "The build target adds polyfills." No. esbuild's target rewrites syntax only. Polyfills are lesson 22.5's story.
- "Source maps slow down every visit." No. Chrome asked for no map in any load. Inline maps are the exception: they ride inside the code.
- "A map on the server is only for DevTools." No. Anyone can fetch it, and read every original file inside.
Practice exercises
Answer each one first, then open the hints if you need them.
Ravi minifies this file with --minify, as an ES module. Which of its three names, addTax, amount and rate, can you still find in the output?
export function addTax(amount) {
const rate = 0.2;
return amount + amount * rate;
}function e(r){return r+r*.2}export{e as addTax};addTax. Other files import it, so esbuild keeps it: export{e as addTax}. amount became r. And rate is gone: esbuild put 0.2 straight into the sum, written .2.
With only names shortened, how many KB does the tea list's first load send with Brotli?
124.7 KB. Names alone saved 22.1 KB with Brotli: more than whitespace alone, which saved 12.3 KB.
This is the shape the es2015 build gives size ?? 1. What does it print?
// What the es2015 build wrote for size ?? 1
const size = 0;
var n;
console.log((n = size) != null ? n : 1);0. ?? only skips null and undefined. 0 is a real value, so size ?? 1 gives 0, and so does the rewrite. size || 1 would have given 1.
Built for es2020, how many KB does the tea list's first load send with Brotli?
111.5 KB: 0.4 KB more than es2022. es2020 keeps ?? and ?., but rewrites class fields, with helpers like __publicField.
Open the copy of the tea list with Ben's review in Chrome. Open DevTools' Console, and expand the error. DevTools shows its stack through the source maps. Which of Ravi's files called timeAgo?
reviews.jsx, line 10. It calls timeAgo once for each review. This copy's maps are public on purpose, so DevTools can read them.
webpack picks its source maps with one setting, devtool. Which value writes full maps with no comment in the files, for error reports?
hidden-source-map. webpack's docs: it's useful "if you only want SourceMaps to map error stack traces from error reports". They add: don't deploy the map to the web server.
Check your understanding
For each question, ask: what does Asha's phone get, and what stays with Ravi?
Question 1 of 7Sent with Brotli, which of esbuild's three switches saved the tea list the most on its own?
Choose an answer to see the explanation.
Question 2 of 7The tea list ships with linked source maps. When Asha opens it, does her phone download the maps?
Choose an answer to see the explanation.
Question 3 of 7Ravi wants readable stacks from real visitors' errors, without letting anyone read his code. Which setup does that?
Choose an answer to see the explanation.
Question 4 of 7Why did the tea list built for
es2015send more than the build fores2022?Choose an answer to see the explanation.
Question 5 of 7What does this fallback print, for a date that isn't a date?
Read the code, then predictconst UNITS = [["day", 86400], ["hour", 3600]]; const seconds = NaN; const [unit, size] = UNITS.find(([, length]) => Math.abs(seconds) >= length) ?? ["second", 1]; console.log(unit, size);Choose an answer to see the explanation.
Question 6 of 7Lighthouse's Minify JavaScript audit lists no file for the whitespace-only build. What does that tell Ravi?
Choose an answer to see the explanation.
Question 7 of 7Ravi builds without --minify, to read the code, and forgets the define for process.env.NODE_ENV. What happens to React?
Choose an answer to see the explanation.
Key takeaways
- A minifier removes spaces and comments, shortens names, and shortens syntax. It keeps exports, properties, text and licenses.
- Judge it by the bytes sent with Brotli. On the tea list, short names saved more than removing spaces.
- Minified, the tea list answered 455 ms sooner on Asha's phone, thanks to the smaller download.
- Minifiers land within a few percent of each other. Choose on speed and on what they keep.
- A build target rewrites syntax only. Too old a target makes the code longer and adds helpers.
- Keep source maps for yourself and your error tracker. Never ship them inline.
- Read dist/ before you deploy: which React went in, which comments stayed, and what each file sends.
Remember the one-liner.
Minify for the bytes, target your visitors' browsers, keep the map for yourself, and read what you ship.
Sources
The facts on this page come from these docs, standards and source code:
- esbuild API: Minify, Evan Wallace.
- esbuild API: Legal comments, Evan Wallace.
- Terser 5.51.2: README, compress options, Terser.
- SWC: Minification, SWC.
- oxc-minify 0.158.0: index.d.ts, Oxc.
- Vite: Build options, Vite.
- esbuild API: Target, Evan Wallace.
- esbuild content types: JavaScript, Evan Wallace.
- Source map format specification (ECMA-426), Ecma TC39-TG4.
- esbuild API: Sourcemap, Evan Wallace.
- esbuild API: Sources content, Evan Wallace.
- Developer Resources: view and load source maps manually, Chrome for Developers.
- Source maps: esbuild, Sentry.
- Devtool, webpack.
- SourceMap header, MDN.
- Improve debugging with the ignoreList source map extension, Chrome for Developers.
- Debug your original code instead of deployed with source maps, Chrome for Developers.
- esbuild API: Platform, Evan Wallace.
- Lighthouse 13.6.0: unminified-javascript.js, Google.
Coming next: Script loading strategies. Ravi's files are as small as he can make them. Next, how and when the page loads them.