Performance & Purging
How content scanning and the JIT engine work, keeping bundle size small, and common pitfalls with dynamic class names.
There's no separate "purge" step anymore
Older Tailwind documentation (and a lot of blog posts still floating around) refer to a purge option — that was Tailwind v1/v2's terminology for a separate post-processing pass that stripped unused classes out of a fully-generated stylesheet. Since Tailwind v3, that entire step is gone. Tailwind now runs on a Just-In-Time (JIT) engine that only ever generates the CSS for classes it can actually find in your source — there's nothing to purge afterward, because nothing unused was ever generated in the first place. The content array (introduced on the introduction page in this track) is what feeds this engine.
How content scanning actually works
At build time, Tailwind reads every file matched by the globs in content, and runs a regex-based scan across the raw text of each file — it does not parse or execute your JavaScript/PHP/Vue/Blade templates, and it does not understand variables, conditionals, or string interpolation. It's looking for anything that looks like a complete, valid utility class name as a plain, literal substring:
// tailwind.config.js
module.exports = {
content: [
'./resources/views/**/*.blade.php',
'./resources/js/**/*.{js,jsx,ts,tsx,vue}',
],
};
For every literal class-shaped string the scanner finds, Tailwind checks whether it's a valid utility (matching one of its known patterns — bg-{color}-{shade}, p-{number}, md:{utility}, and so on) and, if so, generates exactly that one CSS rule. Nothing else. A utility class that's never referenced anywhere in any scanned file is simply never generated — this is what keeps a production Tailwind build small regardless of how enormous the full utility set theoretically is: the output only ever contains classes actually in use.
Why dynamic class names are the classic footgun
Because the scanner works on literal text, not executed code, any class name assembled at runtime from fragments is invisible to it:
// BAD — the scanner never sees a complete class name here, only fragments
const colorClass = `text-${color}-500`;
<!-- BAD — same problem in markup: the scanner can't resolve a template variable -->
<div class="bg-{{ $status }}-100">...</div>
In both cases, the class might render correctly in local development — some dev-mode tooling is more permissive — but silently produce no CSS at all in a production build, because text-${color}-500 and bg-{{ $status }}-100 are not, as literal strings, valid Tailwind classes; the interpolation only becomes a real class name after your own code runs, which happens after the build already finished scanning static files.
The fix is always the same: make every complete class name appear literally somewhere in a scanned file, typically via a lookup object mapping a dynamic value to a full, static class string:
// GOOD — every possible class name appears as a complete, literal string
const statusClasses = {
active: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
inactive: 'bg-gray-100 text-gray-800',
};
function badgeClasses(status) {
return statusClasses[status] ?? statusClasses.inactive;
}
<span :class="badgeClasses(status)">{{ status }}</span>
Now every one of bg-green-100, text-green-800, bg-yellow-100, etc. exists as a complete literal string in a scanned source file, so the JIT engine detects and generates all of them — regardless of which one actually gets chosen at runtime.
The safelist option, for the rare case you truly can't avoid it
Occasionally a class name genuinely can't be made literal in source — often when it comes from a CMS field, an API response, or another package entirely outside your control. For that narrow case, safelist forces specific classes (or a whole pattern of them) to always be generated, bypassing content scanning entirely:
// tailwind.config.js
module.exports = {
content: ['./resources/**/*.blade.php'],
safelist: [
'bg-red-500',
'bg-green-500',
{
pattern: /bg-(red|green|blue)-(400|500|600)/,
},
],
};
safelist should be a last resort, not a default habit — every class it forces in is generated unconditionally, whether it's actually used anywhere or not, which is precisely the bundle-size cost the JIT engine exists to avoid. The lookup-object pattern above should cover the overwhelming majority of "dynamic" cases without ever touching safelist.
Keeping the bundle genuinely small
- Scope
contentprecisely. Listing broader globs than necessary (an entirenode_modulesfolder, unrelated build output directories) doesn't break anything, but it does slow down the scan — keep the globs limited to your actual source files. - Avoid
@applysprawl. Every class folded into a custom component via@applystill has to compile its underlying utilities into that rule — a large@apply-heavy stylesheet duplicates work the plain utility classes already do inline, without saving anything at build time (see the Customization page in this track for when@applyis and isn't a good trade-off). - Prefer the built-in scale over arbitrary values where reasonable. Arbitrary values (
w-[137px],bg-[#3b82f6]) are useful, but each distinct arbitrary value generates its own one-off CSS rule rather than reusing an already-generated utility — a page full of unique arbitrary values generates more total CSS than the same page built from a shared, finite scale. - Let your build tool's own minifier run. Tailwind's JIT output is already lean, but running it through your bundler's standard CSS minification (removing whitespace, merging duplicate rules) is still worth doing for production, exactly as you would for any other stylesheet.
Common mistakes
- Assuming a class works because it renders correctly during local development — some dev servers are more lenient about on-the-fly generation, so a dynamically-built class name can appear to work locally and then vanish entirely once a real production build runs.
- Reaching for
safelistas the default fix for any dynamic class instead of first trying the lookup-object pattern —safelistsilently grows the shipped CSS with classes that may never actually be used on a given page. - Listing a content glob that's too narrow and missing a file type entirely (forgetting
.vueor.tsxin the extension list) — classes used only in that file type are never detected, and the bug looks identical to a dynamic-class-name problem even though the cause is different. - Not realizing that
contentscanning is purely textual — wrapping a class name in a Blade/Vue conditional, comment, or even a disabled code branch still counts as "found," since the scanner never evaluates whether that code path actually runs.