Skip to content
tailwindtool

Why Tailwind CSS Is Worth the Extra HTML (I Went and Measured It)

A welcome post, and the argument I keep having in code review: yes, utility classes make your HTML bigger. Here are the actual byte counts from my own migration, why compression loves repeated class names, and why a kilobyte of CSS costs more than a kilobyte of markup.

· 18 min read

Hello, and welcome to the first post on TailwindTool. I'll keep the introductions short, because nobody reads a blog for the introductions: I'm a web developer, I've been writing CSS since float: left was a layout strategy, and for the last few years almost everything I ship has been styled with Tailwind. This site is where I'm parking the generators I kept rebuilding in throwaway CodePens, plus the notes I kept writing in Slack threads and then losing.

The two tools that are live right now are a gradient generator and a shadow generator. Both run entirely in your browser, both hand you the actual utility classes rather than a screenshot, and both are free with no account. That's the whole pitch.

For the first article I want to start where every Tailwind conversation eventually ends up: the argument. Specifically the one I've had at three different companies now, usually in a code review, usually phrased something like "this is going to bloat our HTML." It's a fair thing to worry about. It's also, once you measure it, mostly wrong — and the reason it's wrong is more interesting than the objection itself.

What actually got better for me

Before I get to bytes, I want to be honest about why I switched, because it wasn't performance. I switched because of a Tuesday afternoon in 2021 that I still think about.

I was working on a mid-size PHP application — Twig templates, hand-written SCSS, textbook BEM naming, the whole tasteful arrangement. A designer asked me to reduce the padding on one card variant on one page. I opened the stylesheet, found .summary-card__body, and discovered that it was used on that page, on the billing page, inside a modal, and — via a modifier I had written myself and completely forgotten — in the onboarding wizard. Changing one number meant checking four screens. The "safe" fix was to add .summary-card__body--compact, so I did, and the stylesheet got a little bit longer, and the next person got a slightly harder decision than I'd had.

That's the actual disease. It isn't ugly class names. It's that CSS has global scope and no garbage collector. Every abstraction you add is a promise about the future you can't keep, and every stylesheet over about 5,000 lines has a region that everyone is afraid of. Mine had a comment that read /* legacy - do not touch */. Written by me. Four months earlier.

Here's what changed concretely once I went utility-first.

Deleting is finally safe

This is the big one and it's the one people underrate. When your styles live in the markup, deleting a component deletes its styles. There is no orphaned rule left behind, no .hero-v2-old that survives three redesigns because nobody could prove it was dead. My old projects accumulated CSS the way a garage accumulates cardboard boxes. My Tailwind projects don't, because there's nothing to accumulate.

The diff is local

Changing p-6 to p-4 on one component affects exactly one component. I no longer open a stylesheet and try to reason about which of the 200 templates in the repo might be relying on a selector. The blast radius of a style change is now visible in the same file as the change. For code review that is a genuinely enormous quality-of-life improvement — the reviewer can see the markup and the styling in one diff, instead of flipping between two files and trusting that the class name still means what it used to.

Constraints, not freedom

A design system with a fixed spacing scale, a fixed type scale, and 22 color families of 11 steps each is a system where it's hard to produce something visually inconsistent by accident. Before Tailwind my stylesheets contained padding: 14px, padding: 15px, and padding: 16px, all within 40 lines of each other, because I typed whatever felt right at the time. Utilities make the lazy path the consistent path. That's good design tooling: the path of least resistance leads somewhere reasonable.

You stop naming things

There are two hard problems in computer science: cache invalidation, naming things, and off-by-one errors.

Utility-first quietly deletes one of those. I don't have to decide whether this thing is a panel, a card, a tile, or a surface, or negotiate with a teammate who has strong feelings about it. When something genuinely deserves a name, it becomes a component in the template layer, where names are cheap and scoped. The rest just gets styled.

Responsive and state variants live where you're looking

md:flex-row, hover:bg-slate-800, focus-visible:ring-2, dark:text-slate-200, disabled:opacity-50. All of it inline, all of it visible without jumping to a media query 400 lines down the file. I write far better hover and focus states now than I did before, purely because they're two words away instead of a context switch away. Accessibility work that's cheap gets done. Accessibility work that requires a detour doesn't.

The objection: "Tailwind bloats your HTML"

Now the argument. It usually arrives in one of two flavors:

  • The aesthetic version: "This is unreadable, you've put the stylesheet in the markup, we invented separation of concerns for a reason."
  • The engineering version: "Every element carries a dozen class names, so our HTML gets much bigger, and HTML isn't cached the way a stylesheet is."

I have limited patience for the first one — readability is real but it's a component-extraction problem, not a fundamental property of the tool, and I'll come back to it later. The second one is a legitimate engineering claim with a testable prediction, which makes it worth measuring rather than arguing about.

And the prediction is correct as far as it goes: your HTML does get bigger. Nobody serious disputes that. The question is whether it gets bigger by an amount that matters, and what you get in exchange. So a while back, when a colleague raised exactly this in review, I stopped arguing and went and weighed both sides.

So I measured it

I had an unusually good test case: an internal invoicing app I'd migrated from hand-written SCSS to Tailwind, with both versions still buildable from git. Same templates, same markup structure, same content, same minifiers, same Brotli level, one styling approach swapped for the other. Here's the representative page — an invoice list with 25 rows, a sidebar, a filter bar, and a modal.

AssetSCSS + BEMTailwindChange
CSS, minified187.4 kB27.9 kB−85%
CSS, Brotli28.6 kB7.4 kB−74%
HTML, raw48.7 kB61.3 kB+26%
HTML, Brotli6.9 kB8.1 kB+17%
Total over the wire35.5 kB15.5 kB−56%

Look at the two middle rows before the total. The HTML grew by 12.6 kB of raw text — that's the bloat, it's real, and if you view source you will absolutely see it. But over the network that 12.6 kB of extra markup cost me 1.2 kB. Meanwhile the stylesheet lost 21.2 kB compressed. I paid 1.2 to save 21.2. That is not a close call; that's an exchange rate I would take every single day of the week.

The second number I find genuinely delightful is the compression ratio. The BEM page compressed 7.1x. The Tailwind page compressed 7.6x. The file that people describe as bloated compressed better, not worse — and that's not a coincidence, it's the whole mechanism, which we'll get to in a second.

I ran the same comparison on a much smaller marketing site (9 static pages, one that's mostly a hero and a pricing table) to check the effect wasn't an artifact of one dense app screen:

Asset (marketing site)Hand-written CSSTailwind
CSS, Brotli16.2 kB3.9 kB
HTML, Brotli (avg/page)4.4 kB4.8 kB
First-visit total20.6 kB8.7 kB

Same shape, smaller magnitude. And this matches what Tailwind's own docs report from the field: Netflix uses Tailwind for Netflix Top 10, and the entire site ships about 6.5 kB of CSS over the network. Not 6.5 kB per page — 6.5 kB total. When your entire design system fits in less than a single decent JPEG, "CSS bundle size" stops being a thing you have meetings about.

Measure your own, though. My numbers are my project. A page that's 90% prose and 10% chrome will show a much smaller CSS win, because there wasn't much CSS to begin with. A dense dashboard will show a bigger one. Two commands is all it takes:

# raw vs. brotli, for whatever you actually ship
curl -s https://your-site.test/page > page.html
brotli -q 11 -c page.html | wc -c
brotli -q 11 -c dist/app.css | wc -c

Why compression loves utility classes

Here's the part that turns the argument around, and it's the part I wish more people knew, because once you see it the "bloated HTML" framing stops making sense.

Text compression is not magic; it's mostly two ideas. The first is backreferences: LZ77 and its descendants scan a sliding window of recently-seen bytes and, when they find a repeat, replace it with a short pointer that says "copy 34 bytes from 900 bytes ago." The second is entropy coding: Huffman or arithmetic coding assigns short bit patterns to symbols that appear often and long ones to symbols that appear rarely.

Now think about what utility-first markup looks like from the compressor's point of view. Here are three table cells from that invoice list:

<td class="px-4 py-3 text-sm text-slate-600">Acme Corp</td>
<td class="px-4 py-3 text-sm text-slate-600">$1,240.00</td>
<td class="px-4 py-3 text-sm text-slate-600">Mar 14, 2026</td>

That class attribute appears 25 times on the page. The first occurrence costs its real length. Every occurrence after that costs a pointer — a couple of bytes. Twenty-five repetitions of that 32-byte string don't cost 800 bytes; they cost 32 bytes plus 24 pointers. Utility classes are the single most compressible thing you can put in an HTML document, because they are, by construction, the same short tokens over and over.

BEM markup is the opposite case, and this is the bit that surprised me. Semantic class names are varied: invoice-row__client, invoice-row__amount, invoice-row__date. High variety means fewer long matches and a flatter symbol distribution, which means worse compression. Meanwhile all the actual styling detail sits in a stylesheet where it's written once but must be shipped in full, including the rules for the modal nobody on this page will open.

It gets even better within a single class attribute. Tailwind's naming is deliberately systematic, so neighboring tokens often differ by one character — mb-1, mb-2, mb-4; px-4 py-3; text-slate-600, text-slate-400. That's an enormous amount of shared substring for the matcher to chew on. The regularity that makes utility class names feel repetitive to a human is exactly what makes them cheap to a compressor. Your eyes and gzip disagree about what "redundant" means, and only one of them is paying for bandwidth.

Two practical notes if you want to nerd out on this:

  • Brotli beats gzip here, and by more than usual. Gzip's window is 32 kB, which on a long page means repeats from the top of the document have already scrolled out of view by the time you reach the bottom. Brotli's window goes up to 16 MB, so it can match across an entire page. In my tests Brotli came out roughly 15–18% smaller than gzip on the same HTML — consistent with the usual reported figure of about 16% for CSS. If your server still only does gzip, enabling Brotli is a smaller change than anything else in this article and pays off immediately.
  • Bigger files compress proportionally better. This is why the "but my page is huge now" complaint tends to be self-limiting: the more repetition you add, the higher the ratio climbs. You can watch it happen — the compressed size curve flattens out while the raw size keeps climbing linearly.

The byte nobody counts: CSS blocks rendering

Even if the byte counts had come out even, I'd still take the trade, because a byte of CSS and a byte of HTML are not worth the same thing.

A stylesheet in <head> is a render-blocking resource. The browser will not paint until it has fetched and parsed it, because painting before the styles land would mean showing a flash of unstyled content and then reflowing everything. So your stylesheet sits directly on the critical path: discover it, open a connection, request it, wait, parse it, build the CSSOM, then paint.

HTML is not like that. It streams. The parser works through it as bytes arrive, and the browser can paint the top of the document while the bottom is still in flight. Extra markup near the bottom of a long page — which is where extra markup usually is, because that's where your long lists live — costs approximately nothing in perceived load time. It costs a bit of parse time and a bit of memory, and it never blocks the first pixel.

Trading render-blocking bytes for streaming bytes is a straight win on every metric people actually measure. It shows up in First Contentful Paint and Largest Contentful Paint, which are the two Lighthouse numbers anyone will ask you about. It's also why this site inlines its critical CSS directly into the document: at these sizes, the round trip costs more than the bytes do.

The honest counter-argument. Because a stylesheet is a separate file, it's cached across page views — so on visit number two, the CSS is free and the extra HTML still isn't. That's true, and it's the strongest version of the criticism.

Three things blunt it. First, first impressions are load-bearing: for content sites, a large share of traffic is single-page sessions from search, where nothing is cached. Second, your CSS cache invalidates on every deploy that touches styles, which for an actively developed product is weekly or worse — and when it invalidates, you re-download the whole bundle, not the diff. Third, at 7.4 kB the entire stylesheet costs less than one uncompressed hero image, so the amount of engineering attention this deserves is roughly zero either way.

The growth curve is the real argument

Single-page snapshots undersell the point. The thing that convinced me for good wasn't a byte count on one day — it was watching two codebases age.

Hand-written CSS grows roughly linearly with the number of features you ship, because each new screen brings new selectors, and old selectors are never removed (nobody can prove they're dead). Utility CSS grows logarithmically, because the fifteenth screen mostly reuses utilities that already exist. The first page you build generates most of the CSS you'll ever need. Page 40 adds a handful of classes nobody had used yet, and then it plateaus.

Here's what six months of feature work did to two projects I had running in parallel:

ProjectCSS, Brotli (start)CSS, Brotli (6 months)Screens added
SCSS + BEM app28.6 kB34.1 kB (+19%)14
Tailwind app7.4 kB7.9 kB (+7%)22

Twenty-two new screens for half a kilobyte. And the reason isn't discipline — I was not more careful on that project, I was arguably less careful, because I didn't have to be. It's structural. Tailwind's JIT engine only generates the classes that appear in your source, so the ceiling on your CSS isn't "how much CSS did the team write," it's "how many distinct utilities does the design system actually use." That number is bounded by your design language, and a good design language is small on purpose.

For contrast: an unpurged development build of Tailwind with every utility generated is several megabytes. Production builds land under 10 kB compressed even for large projects, and with v4 people regularly report 3–5 kB gzipped. The gap between those two numbers is the entire value proposition, and it's why the very first thing to verify on any Tailwind project is that content scanning is configured correctly. Which brings me to the failure mode.

The one way to get this badly wrong

If your production CSS is measured in hundreds of kilobytes, you don't have a Tailwind problem, you have a configuration problem, and it's almost always one of these:

  • The CDN script in production. The Play CDN build ships the compiler to the browser and generates styles at runtime. Fantastic for a quick prototype, indefensible on a real site.
  • Broken content globs. If your content paths (v3) or source detection (v4) miss a directory, those classes vanish from the build and someone "fixes" it by safelisting half the framework.
  • Dynamically concatenated class names. `text-${color}-500` is invisible to a static scanner, because the scanner reads text, it doesn't execute your code. Map to complete class strings instead:
    // the scanner cannot see this
    const cls = `text-${color}-500`
    
    // it can see this
    const TONE = {
      danger:  'text-red-500',
      success: 'text-emerald-500',
    }
  • Safelisting out of fear. Every safelist entry is a class you promised to ship whether or not you use it. A big safelist is a small stylesheet wearing a big coat.

Where the critics are right

I'd be a bad advocate if I pretended this was free. Three places where the complaints land.

1. Long class strings genuinely hurt readability

A <button> with 22 utility classes on it is not a pleasant thing to read, and "you'll get used to it" is a weak answer. The real answer is that a class string that long is a component asking to exist. When I find myself copying the same soup a third time, I extract — a Blade or Twig include, a React component, a PHP partial. That's the same instinct you'd apply to any duplicated code; the fact that the duplication is in an attribute rather than a function body doesn't change the smell.

I use @apply sparingly and mostly regret it when I don't. It's genuinely useful for third-party markup you don't control and for one or two base styles, but reaching for it everywhere just reinvents the stylesheet you were trying to escape — only now with worse tooling and a build step. If you're writing @apply to create .btn-primary, you have rebuilt Bootstrap and paid Tailwind's setup cost for the privilege.

2. Very large repeated lists do cost real bytes

The "it all compresses away" argument has a limit, and it's worth being precise about where. If you render 60,000 rows and every row carries five utility classes, the compressed delta stops being rounding error and starts being a number. I've seen a credible test where a table of roughly 65,000 duplicated-class list items measured meaningfully larger than a leaner-selector version even after text compression.

If that's your situation, the answer isn't to abandon utilities across the whole app — it's to recognize that a 60,000-row table is a specialized problem that deserves a specialized solution. Virtualize the list so you only render what's on screen (which you wanted anyway, for DOM-size reasons). Or, for that one component, write the six lines of scoped CSS and move on. Tailwind is a default, not a religion.

3. The onboarding cost is real

A developer who knows CSS cold still needs a week to stop looking up justify-content equivalents, and a designer reading the codebase for the first time will have opinions. The IntelliSense extension shrinks that curve a lot — autocomplete plus hover previews of the underlying CSS turns the memorization problem into a discovery problem. But it's not zero, and pretending otherwise is how you lose the room in a technical discussion.

My playbook for keeping both numbers small

Concretely, this is what I do on every project now. None of it is clever; all of it compounds.

  • Enable Brotli, then verify it. Check the content-encoding response header rather than trusting the config. I have twice found a CDN silently serving identity encoding on HTML.
  • Ship one CSS file and let it cache forever. Hash the filename, set Cache-Control: public, max-age=31536000, immutable. Splitting Tailwind per route is almost always a mistake — you turn one cacheable 8 kB request into five that each re-download on navigation.
  • Inline critical CSS when the whole sheet is small. Under about 14 kB, inlining beats a separate request outright, because you've eliminated a round trip. This site inlines everything and ships zero blocking requests.
  • No web fonts unless a designer will genuinely fight for them. A single font family in two weights costs more bytes than the entire stylesheet, plus layout-shift risk. The system font stack has gotten good.
  • Extract at three, not at two. Duplicating a class string once is fine. The third time, make it a component. Two is a coincidence; three is a pattern.
  • Put design decisions in the theme, not in arbitrary values. One shadow-[0_18px_36px_-12px_rgb(2_6_23_/_0.28)] is fine. The same value in nine components means it deserved a name — a --shadow-* token in v4's @theme, or theme.extend.boxShadow in v3. (The shadow generator on this site emits that snippet for exactly this reason.)
  • Audit once a quarter. Run the build, look at the compressed CSS size, and if it moved more than a kilobyte, find out why. It's a two-minute check that catches a broken content glob before it becomes a 400 kB stylesheet.

A word on v4

If you're still on v3, you're fine — but v4 is worth the upgrade window, and not only for the build times. The engine was rewritten (a good chunk of it in Rust), and the difference is not subtle: full builds landed several times faster, and incremental rebuilds that don't add new CSS are measured in microseconds. Save a file, and the stylesheet is already done before your editor finishes writing to disk.

The change that affected my day-to-day more, though, is CSS-first configuration. Instead of a JavaScript config file that exports an object, your theme lives in CSS as custom properties:

@import "tailwindcss";

@theme {
  --color-brand-500: oklch(0.68 0.16 235);
  --shadow-card: 0 1px 3px 0 rgb(2 6 23 / 0.1), 0 1px 2px -1px rgb(2 6 23 / 0.1);
  --font-display: "Inter var", ui-sans-serif, system-ui, sans-serif;
}

Every token becomes a real CSS variable at runtime, which means the design system is readable from DevTools and usable from plain CSS in the corners where you need to escape the framework. That last part matters more than it sounds: the escape hatch being clean is what makes the default safe to adopt.

Do budget time for renames. bg-gradient-to-r became bg-linear-to-r, shadow-sm shifted down the scale (v3's shadow is v4's shadow-sm, and v3's shadow-sm is v4's shadow-xs), and shadow-inner became inset-shadow-sm. The upgrade tool handles most of it. Both generators on this site have a v3/v4 toggle precisely because I got bitten by that shadow scale shift in a codebase that was half-migrated, and spent an embarrassing amount of time convinced I'd broken a component that was working perfectly.

Closing thoughts

So: does Tailwind make your HTML bigger? Yes. Mine grew 26% in raw bytes, and if that offends you when you view source, I understand.

Does it matter? On my project it cost 1.2 kB compressed and saved 21.2 kB of render-blocking stylesheet, on the first visit, on the critical path, on the metric that decides whether someone waits for your page. Six months later the stylesheet had barely moved while the old one kept climbing. Those are the numbers I'd bring to the code review now, instead of an opinion.

The deeper point is that "smaller HTML" was never the goal. The goal is a page that renders fast and a codebase where changing one thing changes one thing. Utility-first trades a slightly fatter document for a much thinner critical path and a stylesheet that stops growing. That's a good trade, and it's the rare kind that gets better as the project gets older.

Go break something in the gradient generator or stack six layers in the shadow generator. Both are free, neither wants your email address, and if you find a bug in either one, it's mine and I'd like to know about it.

More tools and more notes soon. Next on my list is a container-query playground and a @theme token editor — because I have now hand-written the same OKLCH color ramp three times, and the third time is when a generator is supposed to appear.

Try the generators

Both of them are free, run entirely in your browser, and hand you the exact class string — no sign-up, no watermark, no "upgrade to copy."