What a Minifier Can Remove, and What It Must Not
Published 5/19/2025 · 16 min read · Developer tools
Daniel Okonkwo — Front-end developer and tech writer at Allin
Web performance · File formats
Checked against 6 sources
A minifier may only make changes that are semantics-preserving, and the hard cases are all whitespace that is not decoration. In CSS, the space in "div p" is a descendant combinator: remove it and the selector matches something else entirely. The spaces around >, + and ~ can go — esbuild turned ".card > footer" into ".card>footer" while leaving ".card .card-title" untouched. Inside calc(), the spaces around + and − are mandatory: Chrome reports CSS.supports for calc(100% - 2px) as true and for calc(100%-2px) as false, and assigning the latter leaves the property empty. Spaces around * and / are optional. In HTML, whitespace between inline elements is rendered content — two spans measured 36.92 px wide with a space between them and 27.28 px without, a 9.64 px shift — and it is fully significant inside pre and textarea. In JavaScript, automatic semicolon insertion means joining lines changes behaviour: a function whose return is on its own line returns undefined until you join it, at which point it returns the object. And renaming stops at the string boundary; property mangling turned a working lookup into undefined. As for the payoff, on this page's assets brotli alone saved 66.2% and minifying first added only 18.1% more.
Minification has to preserve meaning, and the interesting part is the whitespace that carries meaning: the descendant combinator, the spaces inside calc(), the gap between two inline elements. Measured here on real files, including how much brotli was going to save you anyway.
The only rule: the output must behave identically
Minification is a compiler pass with one contract: the output must be observationally identical to the input. Not similar, not close enough — identical in every behaviour a page can depend on. Everything a minifier does follows from that, and every minification bug is a place where somebody assumed a byte was decoration when the specification says it is data.
That distinction is why regular-expression minifiers are dangerous and parser-based ones are not. A tool that strips whitespace with a pattern has no idea whether a given space is separating two tokens for legibility or joining two tokens into a compound meaning. A tool that tokenises the input according to the CSS Syntax module or the ECMAScript grammar, builds a tree and re-emits it, cannot make the mistake at all, because by the time it prints anything the meaning is already fixed in the tree.
Everything below was run rather than recalled. The samples were minified with esbuild, sizes were measured with node's zlib at gzip level 9 and brotli quality 11, and the CSS and layout claims were checked in headless Chrome.
CSS: the space that is a combinator
In a selector, whitespace between two compound selectors is the descendant combinator. "div p" selects every p inside a div; "divp" selects a nonexistent element type, and ".card .card-title" selects a .card-title inside a .card while ".card.card-title" selects one element carrying both classes. The space is a token, not formatting, and no correct minifier removes it.
The other three combinators are punctuation and their surrounding spaces are free. Feeding esbuild the four forms "div p", "div>p", "div + p" and "div ~ p" returned exactly "div p", "div>p", "div+p" and "div~p". The descendant space survived; the spaces around >, + and ~ did not, because those characters are unambiguous on their own. In the sample stylesheet the same thing happened to real rules: ".card .card-title" came through intact while ".card > footer", ".card + .card" and ".card ~ .aside-note" tightened up.
The at-rule case is subtler and worth staring at. The media query "@media (min-width: 600px) and (max-width: 900px)" came out as "@media(min-width:600px)and (max-width:900px)". The space before "and" went, because a closing parenthesis already ends the previous token. The space after "and" stayed, because "and(" would tokenise as a function token rather than as an identifier followed by a parenthesis. That is the whole discipline in one line: a space is removable exactly when the two tokens on either side of it cannot merge into a different single token.
CSS: the spaces inside values
calc() is the sharpest example, because the requirement is asymmetric. The CSS Values specification demands whitespace on both sides of + and −, since without it a token like "-2px" lexes as a single negative dimension and the expression loses its operator. Chrome agrees precisely: CSS.supports for width and calc(100% - 2px) returns true, while calc(100%-2px), calc(100% -2px) and calc(100%- 2px) all return false. Assigning style.width = "calc(100%-2px)" leaves the property as an empty string, because the whole declaration is dropped as invalid.
The multiplication and division operators have no such problem, and the browser confirms it: calc(100%*2) and calc(100%/2) both report true. A minifier that understood the grammar could therefore squeeze those two and not the others. In practice esbuild is conservative and kept every space inside "calc(100% - 2 * var(--gap))" — correct for the minus, and a small missed opportunity for the star.
Two more categories of untouchable space showed up in the same run. String values are literal: the declaration content: " new " kept both pairs of interior spaces, because those characters are inserted into the document. And custom properties are token streams rather than parsed values, so esbuild left "--card-bg: #ffffff" with the space after its colon while stripping the identical space from "color: var(--card-fg)". The rest of the sample shows what a minifier gains when it does understand the value grammar: rgba(0, 0, 0, 0.08) became #00000014, #0000ff became #00f, 150ms became .15s, opacity 0.7 became .7, margin: 0 0 8px 0 became margin:0 0 8px, and ::after became :after.
HTML: whitespace between inline elements is content
CSS white-space processing collapses a run of whitespace in normal flow into a single space — but a single space, not nothing. Between two inline-level elements that space is rendered and occupies width, so deleting it moves the layout. Measured in headless Chrome at 16 px monospace, two adjacent spans separated by a newline in the source ended at x = 36.92, while the same two spans written with no whitespace between them ended at x = 27.28. The difference of 9.64 px is exactly one space character, and it is the difference between a row of links reading "one two three" and one reading "onetwothree".
This is why aggressive HTML minifiers are configurable and why their defaults are usually cautious. Collapsing a run of five spaces and two newlines into one space is always safe in normal flow. Deleting the last remaining space between two inline boxes is not, and a minifier that does it as a blanket rule will silently reflow menus, breadcrumb trails, tag lists and inline icons. Any tool that offers to remove whitespace between tags is offering to change your layout in exchange for bytes.
Two elements are absolutely off limits: pre and textarea. Both default to white-space: pre, so every space, tab and newline inside them is preserved and rendered. The sample page here contains an indented code block and a textarea with meaningful leading spaces, and the whitespace collapser used for the measurement had to be given an explicit exception for both. Any minifier without that exception silently destroys code samples and pre-filled form fields. The same care applies inside script and style elements, and to a leading newline immediately after an opening pre tag, which the HTML parser drops by specification — a subtlety that makes hand-rolled tooling wrong in both directions.
JavaScript: automatic semicolon insertion and renaming
ECMAScript inserts semicolons at certain line breaks, which makes a newline semantically load-bearing. The canonical case is a return on its own line. Executing function f(){ return \n { ok: true } } returned undefined, because a semicolon is inserted straight after return. Writing the same code on one line returned { ok: true }. A naive minifier that joins lines therefore changes the value the function produces. The reverse also bites: the snippet let x = 1 \n ++x evaluates x to 2, while joining those two lines throws SyntaxError: Invalid left-hand side expression in postfix operation.
A parser-based minifier cannot make either mistake, because by the time it prints, the semicolon has already been decided. Feeding the same return-on-its-own-line function to esbuild produced function t(){}export const r=void 0; — it kept the semantics, saw the function could only return undefined, and folded the call to void 0. That is the difference between a text transformation and a compiler.
The other JavaScript hazard is renaming, and its boundary is exact: a minifier may rename anything whose every reference it can see, and nothing else. Local variables and function parameters qualify, which is where most of the saving comes from. Object property names do not, because a property can be reached by a string the minifier cannot follow. Demonstrated: a module returning [config.userName, o["userName"], o["retryCount"]] gave ["ada", "ada", 3] under plain minification, and ["ada", undefined, undefined] once property mangling was switched on. The dot access was renamed along with the definition; the two string lookups still asked for the old names and found nothing. The same trap catches anything reached by name at runtime — bracket access built from a variable, JSON round-trips, framework bindings, and direct eval.
Measured: what minification is worth after compression
Minification and compression remove overlapping redundancy, so the second one to run always looks less impressive. On the hand-written samples: the CSS went from 1,434 to 1,066 bytes, a 25.7% cut, but after brotli the pair was 552 against 459 — only 16.8%. The JavaScript went from 1,518 to 747 bytes, a 50.8% cut, but 552 against 395 after brotli, 28.4%. The HTML went from 1,033 to 782 bytes, 24.3%, and 322 against 302 after brotli — 6.2%, or twenty bytes.
Combining the three into one page payload puts the honest number on the table. The raw assets total 3,985 bytes and brotli takes them to 1,346 — a 66.2% saving from compression alone, with no build step. Minifying first and then compressing gives 1,102 bytes. So minification's marginal contribution, on top of a compression layer you already have, is 244 bytes: 18.1%. Real and worth taking, but an order of magnitude less than the raw-byte figure implies.
Four real files from this repository show how much the answer depends on what is in the file. globals.css shrank 68.2% raw and 70.2% after brotli — spectacular, and explained entirely by the fact that 2,084 of its 3,393 bytes are comments, with all thirteen rule blocks intact on both sides. app-shell.module.css, which is mostly real declarations, gave 4.4% raw and 5.8% after brotli. Two content-heavy TypeScript modules gave 10.8% and 6.5% raw, but only 3.4% and 2.3% after brotli, because a file that is mostly string literals has almost nothing a minifier is allowed to touch. The rule of thumb: minification pays in proportion to how much of your file is comments, indentation and long local identifiers, and pays nothing on data.
A working order of operations
Turn compression on first, because it is the largest single win, it needs no build step and it cannot break anything. On these samples brotli alone removed 66.2% of the bytes. Then minify with a parser-based tool for each language, at its default settings. Then, only if you have a measured reason, reach for the aggressive options — property mangling, whitespace removal between tags — and treat each of them as a change that needs testing, because each is a place where the semantics-preserving contract has been deliberately relaxed.
Two habits are worth more than any minifier setting. Strip comments and dead code at the source rather than relying on the minifier to notice them — globals.css was 61% comments, and that single fact accounted for its entire 68% reduction. And check the output, not the promise: run the minified bundle through your test suite, and compare the compressed sizes on both sides rather than the raw ones, because the raw number is the one that flatters and the compressed number is the one your users actually download.
| Before | After | Safe? | Why |
|---|---|---|---|
| .card > footer | .card>footer | Yes | The > is unambiguous on its own; the spaces carry nothing |
| .card .card-title | .card .card-title (unchanged) | Must not change | The space is the descendant combinator; removing it selects one element with both classes |
| calc(100% - 2px) | calc(100% - 2px) (unchanged) | Must not change | Chrome reports calc(100%-2px) as unsupported and drops the declaration |
| content: " new " | content:" new " (spaces kept) | Must not change | String contents are inserted into the document verbatim |
| rgba(0, 0, 0, 0.08) | #00000014 | Yes | Eight-digit hex is an exact representation of the same colour |
| margin: 0 0 8px 0 | margin:0 0 8px | Yes | The shorthand mirrors the second value when the fourth is omitted |
| return on its own line | newline removed by a text tool | No | The function returned undefined before and the object after |
| o.userName and o["userName"] | property mangling renames only the first | No | The measured result went from [ada, ada, 3] to [ada, undefined, undefined] |
Frequently asked questions
- If my server already sends gzip or brotli, do I still need to minify?
- Yes, but expect a much smaller gain than the raw numbers suggest, and get the compression working first. Measured on the sample page, brotli alone took 3,985 bytes of raw assets down to 1,346 — 66.2% saved with no build step at all. Minifying before compressing reached 1,102 bytes, so minification's marginal contribution was 244 bytes, or 18.1% on top. That is worth having, and it costs nothing per request once your build does it. The reason the two overlap is that they attack the same redundancy: repeated long identifiers, indentation runs and comment text are exactly what a dictionary compressor is best at eliminating. Where minification still wins outright is on things compression cannot do, because they are semantic rather than textual — dead-code elimination, constant folding, dropping unreachable branches, shortening colour and unit syntax. The order that matters: compression on, then minify, then measure the compressed sizes rather than the raw ones.
- My layout shifted after I turned on HTML minification. Why?
- Almost certainly because the minifier removed whitespace between inline-level elements, which is rendered content rather than formatting. CSS white-space processing collapses a run of spaces and newlines in normal flow down to one space — one, not zero — and that surviving space occupies width between two inline boxes. Measured in headless Chrome at 16 px monospace, two spans with a newline between them in the source ended at x = 36.92, and the same pair with no whitespace at all ended at x = 27.28: a 9.64 px difference, exactly one space. Across a navigation bar, a list of tags or a row of inline links, that difference is immediately visible and reads as a bug. Look for an option named something like collapseWhitespace with an aggressive or conservative mode, and prefer the conservative one. If you want no gap between two inline elements, remove it in CSS with a flex or grid container, or with font-size on the parent, so the markup stays independent of the layout.
- Is it ever safe to rename object properties?
- Only when you can guarantee that every access to the property is visible to the minifier, which in practice means adopting a naming convention and telling the tool about it. The common pattern is to mangle only properties matching a pattern such as a trailing underscore, so internal fields are renamed and everything public is untouched. What breaks it is any access by string. Demonstrated here: a module returning [config.userName, o["userName"], o["retryCount"]] gave ["ada", "ada", 3] under ordinary minification and ["ada", undefined, undefined] once property mangling was enabled — the dot access moved with the definition, the two string lookups did not. The same failure mode covers bracket access built from a variable, keys arriving from JSON, framework templates that bind by name, and anything reflected over with Object.keys. Because the breakage is silent and only shows up on the code path that uses the string, treat property mangling as an optimisation that requires a full test run, not a checkbox.
- Can I write a minifier with regular expressions?
- You can write something that usually works, which is the worst possible outcome, because the failures are rare and silent. A pattern that strips runs of whitespace cannot tell a descendant combinator from indentation, cannot tell that the space before a minus inside calc() is grammatically required, cannot see that a newline before a closing brace is what makes a return statement return undefined, and cannot tell that the characters inside a quoted string are content. Every one of those distinctions requires tokenising the input according to the actual grammar. The whitespace collapser used for the HTML measurement in this article is deliberately naive, and it needed an explicit exception carved out for pre and textarea before it produced correct output at all — and it still would not be safe on a page with inline scripts containing angle brackets in strings. The practical answer is to use a parser-based tool per language and spend your effort on the input instead: fewer comments shipped, no dead code, shorter local names where they do not hurt readability.
- Why did minifying my data-heavy file barely help?
- Because a minifier is only allowed to touch syntax, and a data file is almost entirely content. String literals must survive byte for byte, object keys used at runtime cannot be renamed, and numbers are already as short as they will get. The measurements in this article show the pattern clearly: two content-heavy TypeScript modules from this repository shrank 10.8% and 6.5% raw, but only 3.4% and 2.3% after brotli, because the little that minification removed was indentation and punctuation the compressor was going to squeeze anyway. Compare that with globals.css, which shrank 68.2% — entirely because 2,084 of its 3,393 bytes were comments. If a data file is genuinely large, the lever is not minification but the format and the delivery: move the data behind an API so pages fetch only what they show, split it so a route loads its own slice, or move it out of the JavaScript bundle into JSON that the browser can parse faster and cache separately.
Articles you may find interesting
All guides →Related tools
Sources
- W3C — CSS Syntax Module Level 3 — tokenization rules that decide which whitespace is removable
- W3C — CSS Values and Units Module Level 3 — the calc() grammar requiring whitespace around + and −
- W3C — CSS Text Module Level 3 — white space processing and the white-space property
- W3C — Selectors Level 4 — the descendant combinator is whitespace
- WHATWG — HTML Standard — parsing, the pre element's leading newline, and raw text elements
- Ecma International / TC39 — ECMAScript Language Specification — Automatic Semicolon Insertion
Spotted a mistake in this article?