Skip to content
Allin

What a Minifier Can Remove, and What It Must Not

Published 5/19/2025 · 16 min read · Developer tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at Allin

Web performance · File formats

Checked against 6 sources

View profile
In short

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.

Transformations run through esbuild on the sample stylesheet and module, with the verdict on each
BeforeAfterSafe?Why
.card > footer.card>footerYesThe > is unambiguous on its own; the spaces carry nothing
.card .card-title.card .card-title (unchanged)Must not changeThe space is the descendant combinator; removing it selects one element with both classes
calc(100% - 2px)calc(100% - 2px) (unchanged)Must not changeChrome reports calc(100%-2px) as unsupported and drops the declaration
content: " new "content:" new " (spaces kept)Must not changeString contents are inserted into the document verbatim
rgba(0, 0, 0, 0.08)#00000014YesEight-digit hex is an exact representation of the same colour
margin: 0 0 8px 0margin:0 0 8pxYesThe shorthand mirrors the second value when the fourth is omitted
return on its own linenewline removed by a text toolNoThe function returned undefined before and the object after
o.userName and o["userName"]property mangling renames only the firstNoThe measured result went from [ada, ada, 3] to [ada, undefined, undefined]
CSS CompressorMinify CSS by stripping comments and whitespace to shrink your stylesheet's size.Try the tool

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
GuideBeautify or Minify: What Each Is For, and What It Does to the WeightFour real stylesheets run through the minifier, measured raw and after gzip. Stripping every space saved 48, 103, 104 and 147 compressed bytes; stripping comments saved 57, 1 358, 2 420 and 1 042. Plus the five inputs this minifier breaks.How-toHEX vs RGB: How to Read and Convert ColoursHEX and RGB are two notations for the same colours. Here's how to read each, convert between them, and add transparency.ExplainerGradients, Banding, and Why the Middle Looks MuddyInterpolating in sRGB averages gamma-encoded numbers, so the midpoint of red to green is #808000 when the half-light answer is #bcbc00 — 57.2% too little light. Banding is a separate arithmetic problem: 8 bits give 256 steps, and a dark gradient may have only 28 of them. Here is both, computed.ExplainerColour Schemes Are Geometry on a Wheel — and the Wheel Is WrongComplementary, triadic, analogous and split-complementary are just rotations: add 180°, 120°, 30° or 150° to a hue. The arithmetic is trivial. The problem is that the HSL hue circle is not perceptually uniform — yellow and blue at the same HSL lightness differ in luminance by 12.85 times — so a generated palette has to be contrast-checked afterwards.ExplainerContrast Ratio: How WCAG Actually Computes ItThe WCAG ratio is (L1 + 0.05) ÷ (L2 + 0.05), and L is relative luminance, not brightness. Green carries 71.52% of it and blue 7.22%, which is why pure blue on white passes at 8.59:1 while mid grey fails at 3.95:1. Here is the whole computation, run end to end.ExplainerHow Instagram Fonts Actually Work (They Are Not Fonts)Nothing is styled. Each letter is swapped for a different Unicode character that happens to look bold or cursive — which is why a screen reader reads the result as gibberish and some devices show empty boxes.

Related tools

Sources

Spotted a mistake in this article?