completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

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.

By the end, you can
  • 01
    Read what a minifier didTell spaces, short names and shorter syntax apart, and know what each one saves once Brotli has packed the file.
  • 02
    Choose a build targetSee which syntax a target rewrites into older, longer code, and what that costs the tea list.
  • 03
    Ship 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:

The start of dist/app.js two ways. Built without --minify, it has 14,095 lines, and the first ones read: import { / __commonJS, / __toESM, / require_jsx_runtime, / require_react / } from "./chunk-JSER7JSR.js"; / / // node_modules/scheduler/cjs/scheduler.production.js / var require_scheduler_production = __commonJS({ / "node_modules/scheduler/cjs/scheduler.production.js"(exports) { / "use strict"; / function push(heap, node) {. Built with --minify, its code fills 9 lines, and it starts: import{a as Zu,c as Zn,d as Uf,e as _0}from"./chunk-RJHX4K5A.js";var Y0=Zu(r=>{"use strict";function Rf(l,t){var u=l.length;l.push(t);l:for(;0<u;){var a=u-1>>>1,n=l[a];if(0<jn(n,t))l[a]=t,l[u]=n,u=…Built without --minify: 14,095 linesdist/app.js, without --minifyimport {  __commonJS,  __toESM,  require_jsx_runtime,  require_react} from "./chunk-JSER7JSR.js";// node_modules/scheduler/cjs/scheduler.production.jsvar require_scheduler_production = __commonJS({  "node_modules/scheduler/cjs/scheduler.production.js"(exports) {    "use strict";    function push(heap, node) {As Ravi builds it, with --minify: 9 lines of codedist/app.js, with --minifyimport{a as Zu,c as Zn,d as Uf,e as _0}from"./chunk-RJHX4K5A.js";var Y0=Zu(r=>{"use strict";function Rf(l,t){var u=l.length;l.push(t);l:for(;0<u;){var a=u-1>>>1,n=l[a];if(0<jn(n,t))l[a]=t,l[u]=n,u=…Its longest line holds 145,347 characters.
The same file, built two ways. Line 12 of the top one starts 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 on Asha's phone, unminified and minifiedPop out in the code editor (opens in a new tab)JavaScript
// 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 send and Ready 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.

Real-life analogyA recipe without the life story

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 l for heap
In real life: The same soup at the end
In web performance: The same page, doing the same things
The main idea

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 --minify take 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.

time-ago.js, as Ravi wrote itJavaScript
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-whitespace removes spaces, tabs, line breaks and comments.
  • --minify-identifiers shortens names. A name in code is called an identifier.
  • --minify-syntax rewrites 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:

time-ago.js with --minify-identifiersJavaScript
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: UNITS is now u.
  • Line 2: the function is now s. Its three inputs are n, e and o.
  • Lines 3 and 4: seconds is t, unit is a, size is r, and length is i.
  • Line 8 keeps the name that other files use: s as timeAgo. Other files import timeAgo, 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:

time-ago.js with --minifyJavaScript
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. 1000 became 1e3, the same number written in fewer characters. And the two const lines 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:

Playground: turn esbuild's minify switches on and off
time-ago.js with whitespace, names, syntax

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};

  1. time-ago.js304 bytes, from 430
  2. The tea list's first load, as built412.9 KB
  3. With gzip129.1 KB
  4. With Brotli, as Asha gets it111.1 KB
  5. Lighthouse's Minify JavaScript auditlists no file
Try it yourself
Three switches, eight builds

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.

esbuild 0.28.2, target es2022. KB of 1,024 bytes; Brotli at quality 11.

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, switch by switchPop out in the code editor (opens in a new tab)JavaScript
// 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.
The tea list's first load after each minify switch is added, as built and sent with Brotli: no minifying, 924.1 KB built, 146.8 KB sent; + whitespace, 701.2 KB built, 134.5 KB sent; + names, 425.7 KB built, 111.9 KB sent; + syntax (--minify), 412.9 KB built, 111.1 KB sent.Share of the unminified first loadno minifyingbuilt924.1 KBsent146.8 KB+ whitespacebuilt701.2 KBsent134.5 KB+ namesbuilt425.7 KBsent111.9 KB+ syntax (--minify)built412.9 KBsent111.1 KB
Each bar is a share of the unminified first load. Whitespace cut the built file most, but Brotli had packed the spaces already. Shorter names cut the bytes sent the most.

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.

Four minifiers on the same tea list
MinifierFirst load, sent with BrotliTime for 8 filesLicense comments kept
esbuild111.4 KB77 ms5 of 5
Terser, names only110.6 KB773 ms5 of 5
Terser, 1 pass109.3 KB1,378 ms5 of 5
Terser, 3 passes109.1 KB2,539 ms5 of 5
SWC108.2 KB165 ms0 of 5
Oxc109.7 KB39 ms0 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.

Real-life analogyPacking the car boot again

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:

Playground: one tea list, four minifiers
esbuild
  1. The tea list's first load, as built414.0 KB
  2. With Brotli, as Asha gets it111.4 KB
  3. Time to minify all 8 files77 ms, the middle of 3 runs
  4. License comments kept5 of 5
  5. In Chromedrew all 8 teas, no errors
Try it yourself
Same input, each tool's defaults

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.

Terser 5.51.2, SWC 1.16.22, Oxc 0.158.0, esbuild 0.28.2, on an Apple M1 Pro.

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:

time-ago.js built for es2015JavaScript
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 find gives back in _a. If _a isn't null or undefined, 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.

Real-life analogyA recipe for a kitchen with no microwave

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:

Playground: one tea list, four targets
time-ago.js for es2022

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};

  1. time-ago.js, minified304 bytes
  2. The tea list's first load, as built412.9 KB
  3. With Brotli, as Asha gets it111.1 KB
  4. ?? and ?. rewritten0 and 0
  5. Helpers esbuild addednone
Try it yourself
How new may the syntax be?

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.

esbuild 0.28.2, with --minify. es2022 is Ravi's target.

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 date in JavaScriptPop out in the code editor (opens in a new tab)JavaScript
// 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 prints NaN: 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:

A tiny error tracker in the pageHTML
<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:

The stack the tracker caught (the first lines)Text
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 file teas-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:

read-stack.mjs: one line of a stack, back to Ravi's codeJavaScript
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.
What read-stack.mjs printedText
$ node read-stack.mjs teas-OIK5BIF3.js:1:111616
../src/time-ago.js:6:69

Line 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:

Reading one line of the stack. The tracker caught St at teas-OIK5BIF3.js:1:111616. The source map for teas-OIK5BIF3.js leads to src/time-ago.js:6:69, where line 6 of time-ago.js calls format(Math.round(seconds / size), unit);The tracker caughtteas-OIK5BIF3.js:1:111616in a function called Stsourcemapteas-OIK5BIF3.js.mapIt came fromsrc/time-ago.js:6:69format(Math.round(seconds / size), unit);line 6 of time-ago.js, from column 69
The tracker only knows the minified place. The map for that file turns it into Ravi's file, line and column: the 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".

Real-life analogyA spare key under the mat

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.

Who gets the tea list's source map. Asha's phone: linked, the code, no map; hidden, the code, no map. Anyone who asks: linked, the map: 1,001.9 KB; hidden, 404: not found. The error tracker: linked, the map; hidden, the map, from Ravi.LinkedHiddenAsha's phonethe code, no mapthe code, no mapAnyone who asksthe map: 1,001.9 KB404: not foundThe error trackerthe mapthe map, from Ravi
In both ways, Asha's phone downloads no map. Linked, the map sits on the server, so anyone can fetch it. Hidden, only the tracker gets it, from Ravi.

Three more ways are worth knowing:

  • --sources-content=false keeps 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.
A response that names its map in a headerText
HTTP/2 200
content-type: text/javascript
sourcemap: app.js.map

Line 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.

Five ways to ship a source map
The wayThe end of app.jsWho can read Ravi's code
Linked: --sourcemap//# sourceMappingURL=app.js.mapAnyone. The map is on the server, with every original file inside it.
Hidden: --sourcemap=external, maps kept off the serverNothing. 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.mapNobody. The map holds file names and places, not the code.
In a header: SourceMap: app.js.mapNothing. The server names the map instead.Anyone, if the server hands out the map.
Inline: --sourcemap=inlineThe whole map, packed into the comment.Anyone. And every visitor downloads it with the code.
Playground: five ways to ship the source maps
Linked
  1. The last line of app.js//# sourceMappingURL=app.js.map
  2. The tea list's first load, with Brotli111.3 KB
  3. Its map files, as built1,513.5 KB
  4. Map Chrome's debugger was told aboutapp.js.map
  5. Maps the page asked for0
  6. Anyone fetching /app.js.map getsthe map: 1,001.9 KB
  7. Ravi's code inside the mapyes, every file
Try it yourself
Who can see the map?

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.

Loaded once each in Chrome 157, from a server on the course's computer.

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:

mark-ignored.mjs: list the packages in each mapJavaScript
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 under node_modules/.
  • Lines 6 and 7 save the map and print how many files it marked.
What mark-ignored.mjs printedText
$ 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 ignored

In 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.

Real-life analogyProofreading the printed menu

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:

dist/ after Ravi's buildText
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 once

There 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 in dist/
What to checkHowWhat 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:

Send it, keep it, or leave it out?
  • 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
Try it yourself
0 of 6 correct

Sort each thing by where it belongs.

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

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.

Exercise 1 · Warm-upThe name that stays

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?

Starter codePop out in the code editor (opens in a new tab)JavaScript
export function addTax(amount) {
  const rate = 0.2;
  return amount + amount * rate;
}

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

    Exercise 2 · Warm-upNames alone

    With only names shortened, how many KB does the tea list's first load send with Brotli?

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

      Exercise 3 · PracticeA rewritten ??

      This is the shape the es2015 build gives size ?? 1. What does it print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      // What the es2015 build wrote for size ?? 1
      const size = 0;
      var n;
      console.log((n = size) != null ? n : 1);

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

        Exercise 4 · PracticeThe es2020 build

        Built for es2020, how many KB does the tea list's first load send with Brotli?

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

          Exercise 5 · ChallengeThe caller in DevTools

          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?

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

            Exercise 6 · ChallengeHidden maps in webpack

            webpack picks its source maps with one setting, devtool. Which value writes full maps with no comment in the files, for error reports?

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

              Check your understanding

              For each question, ask: what does Asha's phone get, and what stays with Ravi?

              The build output quiz · 7 questionsScore: first tries count
              1. 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.

              2. 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.

              3. 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.

              4. Question 4 of 7Why did the tea list built for es2015 send more than the build for es2022?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does this fallback print, for a date that isn't a date?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              6. 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.

              7. 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:

              Coming next: Script loading strategies. Ravi's files are as small as he can make them. Next, how and when the page loads them.

              CompleteFrontend Clear concepts. Working examples.