Production performance and the honest limits
Keep the output small, understand what preflight does to existing markup, and recognise the cases where plain CSS or CSS Modules is the better answer.
Output size
# build, then look at what you actually shipped
npx @tailwindcss/cli -i src/app.css -o dist/app.css --minify
wc -c dist/app.css
gzip -9 -c dist/app.css | wc -c
# find the outliers: a class you did not expect is usually a source you forgot
grep -o '\.[a-z-]*\\[[^]]*\]' dist/app.css | sort | uniq -c | sort -rn | head| Factor | Effect on output |
|---|---|
| Preflight | A fixed, small base of normalising rules |
| Used utilities | Proportional to the classes actually in your source |
| A wide source glob | Scans tests and fixtures, keeping unused classes alive |
@source inline() | Every declared name, whether used or not |
| Arbitrary values | One rule each, no reuse |
| A component library's own CSS | Often the largest single contributor |
- The output is a build artefact: compress it, serve it with a long cache lifetime and a content hash, and never inline it into every HTML page.
- A typical application stylesheet compresses to somewhere in the tens of kilobytes, which is small next to a single unoptimised image.
- Measure before optimising the framework. In most projects the largest win on a page is an image or a render-blocking third-party script.
💡
The CSS is almost never the performance problem. Check the total transferred bytes per route, the largest contentful paint element, and the number of render-blocking resources before spending an afternoon on stylesheet size - the ordering matters more than the absolute numbers.
Preflight and existing markup
/* preflight removes default margins, unstyled lists and heading sizes */
@import "tailwindcss";
/* scope it away when the page is legacy markup you cannot change */
@import "tailwindcss" source(none);
@source "./app";
/* or restore the browser defaults for one subtree */
@layer base {
.legacy-content h1 { font-size: 2em; margin: 0.67em 0; }
.legacy-content ul { list-style: disc; padding-left: 1.5em; }
.legacy-content button { background: buttonface; border: 1px solid buttontext; }
}| Preflight does | Consequence |
|---|---|
| Removes margins and heading sizes | Headings look unstyled until you set them |
| Removes list markers and padding | Lists look like plain paragraphs |
Sets border-style: solid and width 0 | A border utility just works |
Makes images display: block | No more inline-image baseline gap |
| Inherits font from the root | Form controls match the page |
Preflight is why a Tailwind page has no unexpected spacing - and also why a page of unstyled HTML rendered inside your layout looks like plain text. Restore the defaults for the subtree you do not control rather than removing preflight globally.
When not to use Tailwind
| Situation | Better fit |
|---|---|
| HTML you do not control (emails, CMS, third-party widgets) | Scoped plain CSS |
| A small site with a dozen rules | Plain CSS in one file |
| A design system consumed by other teams | CSS variables plus components, Tailwind underneath |
| Runtime theming per tenant | CSS custom properties set on a root element |
| Rich text from an editor | A scoped prose stylesheet, or the typography plugin |
| Complex state-driven animation | Web Animations API or CSS in a stylesheet |
| Teams with no build step | A prebuilt stylesheet, or plain CSS |
- The real cost of utility-first CSS is readability in markup. On a component you own, that cost is low; on someone else's HTML, it is a change you cannot make.
- If a class list exceeds roughly a screen, the component is doing too much - that is a design problem, not a framework problem.
- Email HTML needs inline styles, so a utility framework that emits a stylesheet is the wrong tool regardless of preference.
- Runtime theming is better served by custom properties on a root element than by generating classes per tenant.
- Mixing Tailwind with CSS Modules in one project is legitimate: utilities for layout and spacing, modules for the parts with genuinely complex selectors.
- Prefer the boring answer. If your team ships faster with plain CSS and it is maintainable, that is the correct choice.
A practical split that works well:
Tailwind layout, spacing, typography, state variants, one-off adjustments
CSS Modules complex selectors, keyframe-heavy components, third-party markup
Custom props anything that must change at runtime or per tenantFAQ
Is Tailwind's output smaller than my hand-written CSS?
Not necessarily, and it is not the point. The generated file contains only the utilities you used, which is usually small, but a careful hand-written stylesheet for a small site will be smaller. The benefit is that the CSS stops growing as the application does - you add markup, not rules.
How do I keep class lists readable at scale?
Extract a component at the third repetition, declare variants with cva rather than accepting raw class strings, sort with the Prettier plugin so diffs are stable, and use
cn() so an override is explicit. None of these reduce the class list; they stop the same list existing twenty times.Related
Arbitrary values, brackets and the scanning model Component patterns without a component library
Last refreshed 2026-09-18.