How minifiers work
See how minifiers shrink JavaScript: removing spaces, shortening names, folding constants and dropping dead code, and which options can change what it does.
- 01Explain the three kinds of changeWhitespace and comments, shorter names, and compressing the code itself.
- 02Predict what gets removedTell which names a minifier may rename, and which code is dead and can go.
- 03Use the options safelyKnow which options, such as
topleveland property mangling, can change what a program does.
What a minifier does
A minifier makes JavaScript smaller without changing what it does, so pages download faster. It parses the code into a tree, changes the tree, and prints it with as few characters as possible. This lesson uses Terser, a minifier written in JavaScript.
import { minify } from "terser";
const code = [
"function addTax(price) {",
" // tax is 20%",
" const rate = 0.2;",
" return price * (1 + rate);",
"}",
"console.log(addTax(10));",
].join("\n");
const result = await minify(code);
console.log(result.code);
console.log(code.length + " -> " + result.code.length + " characters");Lines 3 to 10 hold the code, with a comment and a constant. Line 11 minifies it with Terser's default options, and line 12 prints function addTax(a){return 1.2*a}console.log(addTax(10));.
The comment and spaces are gone, price is now a, and 1 + rate became 1.2. Line 13 prints 116 -> 56 characters: less than half.
import { minify } from "terser";
const code = [
"function addTax(price) {",
" // tax is 20%",
" const rate = 0.2;",
" return price * (1 + rate);",
"}",
"console.log(addTax(10));",
].join("\n");
const steps = [
["spaces and comments", { compress: false, mangle: false }],
["+ shorter names", { compress: false }],
["+ compress", {}],
];
for (const [label, options] of steps) {
const { code: out } = await minify(code, options);
console.log(out.length, label);
}Lines 12 to 14 turn Terser's two main options on one by one. It prints 83 with only spaces and comments removed, 69 with shorter names, and 56 with compression too.
Picture squeezing a long note into a text message. You drop the extra words, use short forms your friend will understand, do the sums yourself, and cut anything that no longer matters. The message is shorter, but it must still mean the same thing.
- In real life: Dropping extra spaces and greetings
- In JavaScript: Removing whitespace and comments
- In real life: "tmrw" for "tomorrow", if the reader knows it
- In JavaScript: Shortening a local name
- In real life: "2 dozen" becomes "24"
- In JavaScript: Folding a constant
- In real life: Cutting a sentence about a plan you cancelled
- In JavaScript: Removing dead code
A minifier removes whitespace and comments, shortens names, and compresses the code itself. Every change must keep what the program does; the options that might not are off by default.
Whitespace and name mangling
Spaces, line breaks and comments never change what code does, so they all go. Renaming is mangling, and it is more careful: a name can only be shortened if the minifier can see every place it is used.
import { minify } from "terser";
const code = [
"function total(items) {",
" let sum = 0;",
" for (const item of items) sum += item.price;",
" return sum;",
"}",
"console.log(total([{ price: 2 }, { price: 3 }]));",
].join("\n");
console.log((await minify(code, { compress: false })).code);
console.log((await minify(code, { compress: false, toplevel: true })).code);Line 11 mangles without compressing. Inside total, items, sum and item become o, t and c: nothing outside the function can see them. But total itself stays, and so do price and console.
Line 12 adds toplevel: true, and now total becomes o too. That is only safe when nothing else uses the name: in a module or a bundle, yes; in a classic script shared with other scripts, maybe not.
total can only be used inside it, so they are safe to shorten. The top-level name, the property price and the global console may be used by other code, so Terser keeps them by default.Terser reuses short names a lot: the function and its parameter can both be called o, because they live in different scopes. The scope analysis lesson explains how tools tell scopes apart.
Dead code and constant folding
Compression rewrites the code itself. Two of its main tools: constant folding works out expressions whose values are already known, and dead-code elimination removes code that can never run or that does nothing.
import { minify } from "terser";
const code = [
"const SECONDS_PER_DAY = 60 * 60 * 24;",
"const DEBUG = false;",
"function log(message) {",
" if (DEBUG) console.log(\"debug: \" + message);",
"}",
"function unused() {",
" return \"never called\";",
"}",
"log(\"start\");",
"console.log(SECONDS_PER_DAY);",
].join("\n");
const { code: out } = await minify(code, { toplevel: true, mangle: false });
console.log(out);Line 15 compresses with toplevel: true and no renaming, so the changes are easy to see. Ten lines of code become one: console.log(86400);.
A replay of what Terser did to this code, not a view inside a JavaScript engine. The final output is Terser's real output.
script
import { minify } from "terser"; const code = [ "const SECONDS_PER_DAY = 60 * 60 * 24;", "const DEBUG = false;", "function log(message) {", " if (DEBUG) console.log(\"debug: \" + message);", "}", "function unused() {", " return \"never called\";", "}", "log(\"start\");", "console.log(SECONDS_PER_DAY);",].join("\n");console.log(out);Press Step to see each change, line by line. This replays what Terser did; it isn't a view inside the JavaScript engine.
60 * 60 * 24. The rest is dead code: a branch that can't run, a call that does nothing, and a function nobody calls. One statement is left.Bundlers use the same idea for build flags. They replace a name such as process.env.NODE_ENV with a string, and the minifier then removes the branches for the other value. Terser's version of this is compress.global_defs, used in exercise 3.
Terser, esbuild, SWC and Oxc
Your build tool usually picks the minifier for you. Read each row of the table as: "this minifier is written in this language, and is used here."
| Minifier | Written in | Where you meet it |
|---|---|---|
| Terser | JavaScript | webpack 5's default minifier, through TerserPlugin. An option in Vite. |
| esbuild | Go | esbuild --minify. Vite's old default; Vite now marks it deprecated. |
| SWC | Rust | Next.js minifies with SWC. |
| Oxc minifier | Rust | Vite's default minifier for client builds. |
The newer ones, written in Go and Rust, are much faster. Vite's documentation says its default, the Oxc minifier, is 30 to 90 times faster than Terser, with output only 0.5 to 2% bigger. They all do the same three kinds of change.
Read each row of the next table as: "this Terser option starts like this, and does this."
| Option | Default | What it does |
|---|---|---|
compress | On | Rewrites code: folds constants and drops dead code. |
mangle | On | Shortens local variable and function names. |
toplevel | Off | Also renames and drops top-level names. |
keep_fnames | Off | Keeps function names, so .name still works. |
mangle.properties | Off | Also renames object properties. Easy to break code with. |
compress.global_defs | None | Replaces named globals, such as DEBUG, with values. |
After minifying, servers usually compress the file again with gzip or Brotli before sending it. The two work well together: the minifier removes what the program doesn't need, and the compressor squeezes repeated text.
Safe and unsafe transforms
A transform is safe when no program can tell the difference. Some options are only safe if your code doesn't do certain things. Here is a program that reads a function's name:
import { minify } from "terser";
function run(code) {
const logs = [];
new Function("console", code)({ log: (value) => logs.push(JSON.stringify(value)) });
return logs.join(" ");
}
const code = "function addTax(price) {\n return price * 1.2;\n}\nconsole.log(addTax.name);";
for (const options of [{}, { toplevel: true }, { toplevel: true, keep_fnames: true }]) {
const { code: out } = await minify(code, options);
console.log(JSON.stringify(options), run(out));
}Lines 3 to 7 run a piece of code and collect what it prints. Line 10 tries three option sets, and line 12 prints what each minified program prints.
By default, it prints "addTax", as before. With toplevel, Terser inlines the function as an anonymous one, and it prints "". With keep_fnames as well, the name survives.
addTax.name. With toplevel, Terser turns the function into an anonymous one, so the name is gone. keep_fnames keeps it.Properties are riskier still, because data from outside uses their real names:
import { minify } from "terser";
const code = [
"const order = JSON.parse('{\"customerName\":\"Ada\"}');",
"const card = { heading: order.customerName };",
"console.log(card.heading);",
].join("\n");
const { code: out } = await minify(code, { mangle: { properties: true } });
console.log(out);Line 8 turns on property mangling. In the output, order.customerName became order.o, but the JSON text still says customerName. So the minified program prints undefined. (heading stayed, because by default Terser leaves property names that browser objects use.)
Minifiers also avoid some changes on their own:
import { minify } from "terser";
const code = [
"function run(code) {",
" const secret = 42;",
" return eval(code);",
"}",
"console.log(run(\"secret + 1\"));",
].join("\n");
console.log((await minify(code)).code);Line 10 prints the output: code and secret are not renamed. The text given to eval on line 6 could use any name, here secret, so Terser keeps every name it can see.
Turn the options on and off
Turn Terser's options on and off. The playground shows Terser's real output, its size, and what both versions print when they run.
const DEBUG=!1;function formatPrice(a){return"$"+(Math.round(100*a)/100).toFixed(2)}function addTax(a){return 1.2*a}console.log(formatPrice(addTax(10)),addTax.name);same output"$12.00" "addTax""$12.00" "addTax"Every change kept what the program prints. Try turning on toplevel with mangle and compress, and leave keep_fnames off.
Turn on toplevel with compress and mangle: the code drops to 102 characters, but addTax.name now prints "n". Turn on keep_fnames and the output grows a little, but prints the same again.
Rules of thumb for real builds:
- Use your build tool's default minifier and options; they are chosen to be safe.
- Don't use function or class names for logic, such as
value.constructor.name === "Cart". - Only mangle properties you mark yourself, for example names that start with
_. - Run your tests against the minified build, and keep source maps for debugging it.
- Removing spaces and comments
- Renaming a local variable
- Turning
60 * 60 * 24into86400 - Removing
if (false) { … } - Renaming a top-level function
- Renaming an object's properties
- Renaming variables a direct
evalcould read - Renaming
consoleorMath
Put each change with when Terser makes it: by default, only if you turn it on, or never, even when it's on.
Common mix-ups
Most mix-ups come from thinking minifying is only about spaces, or that every change is always safe.
- "Minifying only removes spaces." No. It also renames, folds constants and removes dead code.
- "Minified code runs faster." Not much by itself. It downloads and parses faster, because there is less of it.
- "Every name gets shortened." No. Top-level names, properties and globals are kept by default.
- "Minifying makes code secret." No. It is harder to read, but anyone can still read and run it.
- "More options always means smaller and still correct." No.
topleveland property mangling can change what a program does.
The table below puts three of these side by side. Read each row as: "people often think this, but this is true."
| Topic | What people think | What is true |
|---|---|---|
| Minifying | It only removes spaces. | It also renames and rewrites code, and drops code that can't run. |
| Top-level names | They are always safe to rename. | Other scripts may use them, so toplevel is off by default. |
| Properties | They are renamed like variables. | Not by default: data from JSON, the DOM or other code uses the real names. |
Practice exercises
Each exercise runs the real Terser. Guess first, then run it.
What does line 12 print?
import { minify } from "terser";
const code = [
"function total(items) {",
" let sum = 0;",
" for (const item of items) sum += item.price;",
" return sum;",
"}",
"console.log(total([{ price: 2 }]));",
].join("\n");
const { code: out } = await minify(code);
console.log(out.includes("total"), out.includes("items"));import { minify } from "terser";
const code = [
"function total(items) {",
" let sum = 0;",
" for (const item of items) sum += item.price;",
" return sum;",
"}",
"console.log(total([{ price: 2 }]));",
].join("\n");
const { code: out } = await minify(code);
console.log(out.includes("total"), out.includes("items"));The top-level total is kept by default, and the parameter items is shortened. So it prints true false.
What does line 3 print?
import { minify } from "terser";
console.log((await minify("console.log(2 * 3 + 4);")).code);import { minify } from "terser";
console.log((await minify("console.log(2 * 3 + 4);")).code);Terser folds 2 * 3 + 4 into 10, so it prints console.log(10);.
This keeps a DEBUG && check. Change line 9 so Terser knows DEBUG is false. What does your program print?
import { minify } from "terser";
const code = [
"if (DEBUG) {",
" console.log(\"debug mode\");",
"}",
"console.log(\"ready\");",
].join("\n");
const { code: out } = await minify(code);
console.log(out);import { minify } from "terser";
const code = [
"if (DEBUG) {",
" console.log(\"debug mode\");",
"}",
"console.log(\"ready\");",
].join("\n");
const { code: out } = await minify(code, { compress: { global_defs: { DEBUG: false } } });
console.log(out);With DEBUG defined as false, the if can never run, so it is removed: console.log("ready");.
This prints "". Change line 4 so the name is kept. What does your program print?
import { minify } from "terser";
const code = "function addTax(price) {\n return price * 1.2;\n}\nconsole.log(addTax.name);";
const { code: out } = await minify(code, { toplevel: true });
const logs = [];
new Function("console", out)({ log: (value) => logs.push(JSON.stringify(value)) });
console.log(logs.join(" "));import { minify } from "terser";
const code = "function addTax(price) {\n return price * 1.2;\n}\nconsole.log(addTax.name);";
const { code: out } = await minify(code, { toplevel: true, keep_fnames: true });
const logs = [];
new Function("console", out)({ log: (value) => logs.push(JSON.stringify(value)) });
console.log(logs.join(" "));keep_fnames keeps the name on the function, so .name prints "addTax" again.
This prints undefined. Change line 8 so only properties starting with _ are renamed. What does your program print?
import { minify } from "terser";
const code = [
"const order = JSON.parse('{\"customerName\":\"Ada\"}');",
"const cart = { _cache: order.customerName };",
"console.log(cart._cache);",
].join("\n");
const { code: out } = await minify(code, { mangle: { properties: true } });
const logs = [];
new Function("console", out)({ log: (value) => logs.push(String(value)) });
console.log(logs.join(" "));import { minify } from "terser";
const code = [
"const order = JSON.parse('{\"customerName\":\"Ada\"}');",
"const cart = { _cache: order.customerName };",
"console.log(cart._cache);",
].join("\n");
const { code: out } = await minify(code, { mangle: { properties: { regex: /^_/ } } });
const logs = [];
new Function("console", out)({ log: (value) => logs.push(String(value)) });
console.log(logs.join(" "));Only _cache is renamed. customerName keeps its real name, so the program finds Ada again.
What does line 11 print?
import { minify } from "terser";
const code = [
"function run(code) {",
" const secret = 42;",
" return eval(code);",
"}",
"console.log(run(\"secret + 1\"));",
].join("\n");
const { code: out } = await minify(code);
console.log(out.includes("secret"), out.includes("code"));import { minify } from "terser";
const code = [
"function run(code) {",
" const secret = 42;",
" return eval(code);",
"}",
"console.log(run(\"secret + 1\"));",
].join("\n");
const { code: out } = await minify(code);
console.log(out.includes("secret"), out.includes("code"));Terser keeps every name that eval could see, so both secret and code are still there: true true.
Check your understanding
For each question, ask: who else could see this name or this code, and could any program tell the difference? Each answer explains why it's right or wrong.
Question 1 of 6Which names does Terser shorten with its default options?
Choose an answer to see the explanation.
Question 2 of 6What do these two function names print?
Read the code, then predictconst named = function addTax() {}; console.log(JSON.stringify(named.name), JSON.stringify((function () {}).name));Choose an answer to see the explanation.
Question 3 of 6What does reading a renamed property print?
Read the code, then predictconst order = JSON.parse('{"customerName":"Ada"}'); console.log(order.customerName, order.o);Choose an answer to see the explanation.
Question 4 of 6Why is
topleveloff by default?Choose an answer to see the explanation.
Question 5 of 6When can a minifier remove a piece of code?
Choose an answer to see the explanation.
Question 6 of 6Why doesn't Terser rename variables in a function that uses
eval?Choose an answer to see the explanation.
Key takeaways
A minifier makes code smaller by changing it only in ways no program should notice.
- Minifiers remove whitespace and comments, shorten names, and compress the code itself.
- Local names are shortened; top-level names, properties and globals are kept by default.
- Constant folding works out known values; dead-code elimination removes code that can't matter.
- Terser, esbuild, SWC and the Oxc minifier do the same jobs; the newer ones are faster.
toplevel, missingkeep_fnamesand property mangling can change what code does.
Remember the one-liner.
Remove what nobody can see, shorten what only you use, and work out what's already known.
Coming next: Build a tiny JavaScript interpreter. You'll write a tokenizer, a parser and an evaluator of your own.