the optimization pipeline — source → detect → compile → minify → ship
A Tailwind v4 production build is a 5-stage funnel: every stage removes bytes. Content detection throws away the ~10,000 utilities you didn't use; Lightning CSS minifies what remains (shorter hex, merged rules, dropped duplicate selectors); Browserslist decides which modern CSS features get polyfilled down. The result fits in a single small request — typically 8-15KB gzipped.
.html, .tsx, .vue — the raw class strings@source controls scope.dist.css (gzipped). 8-15KB typical.
The Play CDN (this very page) does stages 2-3 in the browser at runtime
via a MutationObserver — that's why it's ~300KB+ and is
never for production. The CLI does the same
work ahead of time and ships only stage 5.
1 · toggle optimizations → see estimated output size
Model of a typical marketing site (~350 unique utilities used). Toggle each stage and watch the estimated shipped size. All on = real production build. All off ≈ what you'd ship if you naively inlined the entire framework.
utilities emitted: — · stages active: —
2 · CSS size comparison — Play CDN vs production build
These are observed ranges for the same ~350-utility marketing site. The Play CDN ships the entire JIT engine + every utility it might need; the production build ships only what your source references.
| approach | raw | minified | gzipped | for production? |
|---|---|---|---|---|
Play CDN <script> |
~1.2 MB | ~650 KB | ~300 KB+ | ❌ never — it's the JIT engine, not your CSS |
v4 CLI build --minify |
~30 KB | ~12 KB | ~4-8 KB | ✅ yes — only used utilities |
| v4 CLI build (no minify) | ~30 KB | — | ~10 KB | ⚠ only for debugging |
| full Tailwind (all utilities, minified) | ~2 MB | ~600 KB | ~200 KB | ❌ you accidentally disabled detection |
| Preflight reset (always included) | ~8 KB | ~6 KB | ~3 KB | — baseline floor you can't drop below |
The takeaway: the gzipped production build is roughly 40-75× smaller than the Play CDN. That gap is the entire reason a build step exists.
3 · browser targeting — Browserslist decides what gets polyfilled
v4 uses Lightning CSS, which reads your browserslist
field (in package.json) and down-levels modern CSS only when a
targeted browser lacks native support. Target newer browsers → smaller
output (fewer polyfills). Target older ones → Lightning CSS expands
oklch(), nesting, color-mix(), and @layer.
| modern CSS feature | Chrome 120+ | Safari 17+ | Safari 15.4 | Firefox 115 (ESR) |
|---|---|---|---|---|
oklch() colors |
native | native | → rgb/hsl polyfill | native |
CSS nesting (&) |
native | → expanded | → expanded | → expanded |
:has() selector |
native | native | native | not supported (FF 121+) |
@layer cascade |
native | native | native | native |
color-mix() |
native | native | → flattened | native |
| relative color syntax | native | → flattened | → flattened | → flattened |
Gotcha: v4 dropped IE entirely — Lightning CSS will not polyfill for
it. If your browserslist includes IE, the build will emit a
warning and skip those targets.
4 · pinned builds — byte-reproducible output
Pin the exact CLI version to make builds reproducible across CI and
developers. @latest can shift your output when a minor release
changes minification heuristics.
5 · splitting strategies — beyond one big file
| strategy | how | tradeoff |
|---|---|---|
| route-based splitting | one CSS file per route (e.g. home.css, dashboard.css) — bundler splits via dynamic import() |
smaller initial download; extra request on navigation; shared utilities dedupe across files |
| critical CSS extraction | inline only above-the-fold CSS in <style>, defer the rest |
fastest first paint; tooling complexity (critters, penthouse) |
| lazy-load non-critical | <link rel="preload" as="style" onload="..."> for below-the-fold |
defers render-blocking; needs JS fallback for no-JS clients |
| single bundle (default) | one dist.css for the whole app |
simplest; best gzip; fine when total < 20KB gzipped |
Rule of thumb: if your single gzipped bundle is under ~20KB, don't split — the HTTP overhead outweighs the savings. Splitting only pays off above ~30KB or when routes have very different utility sets.
intent → pattern
| intent | pattern | why |
|---|---|---|
| ship the smallest CSS | npx @tailwindcss/cli@4.3.1 -i app.css -o dist.css --minify |
tree-shake + Lightning CSS minify in one step |
| only scan specific files | @source "../templates"; in your CSS |
restricts content detection → tighter output. See source_detection. |
| turn auto-detection OFF | @import "tailwindcss" source(none); |
only @source-registered paths are scanned |
| control polyfill targets | "browserslist": ["defaults"] in package.json |
newer targets → fewer polyfills → smaller CSS |
| byte-reproducible CI | pin @tailwindcss/cli@4.3.1 (never @latest) |
minification heuristics can change between minors |
| force-include a dynamic class | @source inline("hidden"); |
v4 safelist — for classes constructed at runtime |
| inspect what shipped | ls -la dist.css && gzip -c dist.css | wc -c |
verify the real gzipped byte count, not estimates |