Quriostack

Why Your Tailwind Bundle Is Bigger Than Expected

Info
Why Your Tailwind Bundle Is Bigger Than Expected
Hermes Smith
·June 29, 2026· 14 min read
1989 0

A teammate pinged the team last month with a screenshot of their production CSS bundle: 412 KB. The team had been on Tailwind v3 for about a year and had never really looked at the CSS output. After 30 minutes of investigation, we got it down to 38 KB — a 91% reduction, with zero visual changes. The fixes were all standard Tailwind best practices that had been gradually forgotten as the codebase grew. If your Tailwind bundle is bigger than you'd expect, here's where to look, with diagnostic commands you can run today and a workflow that prevents the problem from happening in the first place.

Why This Matters

CSS bundle size has real consequences. Every kilobyte of CSS is a kilobyte the browser has to parse, evaluate, and apply to the DOM. On a low-end mobile device, parsing 400KB of CSS can take 200-400ms — that's a measurable chunk of your time-to-interactive. For a SaaS app, that translates to real money: a 100ms TTI delay can drop conversion by 7%, according to research from Google's web performance team. Stripe published data in 2024 showing that every 100ms reduction in CSS parse time was worth 1-2% revenue on their checkout flow.

There's also a CI/CD angle. Larger bundles mean longer upload times to CDNs, slower deploys, and more bandwidth costs. If you're serving CSS from a CDN that charges per GB transferred, a 400KB CSS file served to a million users is 400GB of traffic per refresh — not free. Cloudflare's pricing starts to sting at high bandwidth; even AWS CloudFront bills add up across millions of requests.

Tailwind is supposed to produce tiny CSS by design. The JIT compiler is supposed to generate only the utilities you use. When your bundle balloons, something is wrong — and it's usually fixable in an afternoon. The framework is doing what it was designed to do; the issue is almost always a configuration drift that happened gradually as the codebase evolved.

There's also a maintainability story. A bloated CSS bundle is often a sign that your codebase has accumulated patterns you didn't intend — dynamic class names, forgotten plugins, arbitrary values scattered throughout. Fixing the bundle fixes the underlying code smell too.

The Core Idea

There are six common reasons a Tailwind bundle is bigger than expected. Most teams have at least one of these issues; some have all six. The pattern Observations show most often is "one major issue plus two minor ones" — fixing the major issue gets you 80% of the way, and the minor issues are easy cleanup.

  1. Missing or incomplete content configuration. If Tailwind doesn't know where to look for class names, it can't tree-shake unused utilities. v4's auto-detection helps, but unusual file locations still need explicit declaration. This is the #1 cause of bloated bundles Documentation and common practice have.

  2. Dynamic class names that aren't statically analyzable. Class names constructed at runtime (e.g., bg-${color}-500) can't be detected, so Tailwind either generates everything (worst case) or nothing (silent failure). The worst failure mode is the silent one — you ship a CSS bundle that's missing the classes you need, and your app breaks only at runtime.

  3. Plugin bloat. Every plugin adds utilities to the bundle. Some plugins generate more utilities than you expect, especially if they include responsive or pseudo-class variants. A plugin you installed "just to try" can quietly add 100KB to your CSS.

  4. Arbitrary values that aren't deduplicated. Writing top-[13px] and top-[17px] generates two separate utilities instead of using a token. Across hundreds of components, these arbitrary values accumulate fast.

  5. Unused variants still being processed. If you have hover:, focus:, active:, disabled:, dark:, group-hover:, and responsive variants all enabled, every utility generates up to 12+ variants. Most apps don't use all of these, but they're all processed by default.

  6. Imports from large CSS frameworks that Tailwind includes. Some libraries ship CSS files that include Tailwind utilities, leading to duplication. Bootstrap, Foundation, and some admin templates ship Tailwind utilities inline.

The good news: each of these has a clear fix. The bad news: most teams don't realize they have an issue until someone checks the bundle. Set up a CI check that reports CSS bundle size on every PR; you'll catch this before it becomes a 400KB problem.

A Concrete Example

Let's diagnose a typical over-large Tailwind bundle. Imagine you're seeing 400KB+ of CSS in production. Here's the systematic investigation.

Step 1: Inspect the bundle.

Build your project for production, then check the CSS file size:

Bash
pnpm build
ls -lh .next/static/css/

Open the CSS file and look for:

  • Repeated class names (sign of duplicates)
  • Long arbitrary values that could be tokens
  • Variants you don't use

You can also use a tool like csso or purgecss-cli to analyze what's actually being used:

Bash
npx csso-cli static/css/*.css --output minified.css
wc -c minified.css

Step 2: Check content configuration.

In v4, this is the @source directive:

CSS
@import "tailwindcss";

@source "./src/**/*.{js,jsx,ts,tsx}";
@source "./app/**/*.{js,jsx,ts,tsx}";
@source "./components/**/*.{js,jsx,ts,tsx}";

If any of these is missing or wrong, Tailwind will fail to detect classes in those directories. Conversely, if you have a @source pointing at a directory with generated class names in comments or string literals that aren't real classes, Tailwind generates phantom utilities.

A common mistake: forgetting to include a CMS output directory or a generated route file. Run your app and check the actual build output for unexpected utilities. Look for class names that look like component code — flex items-center, text-gray-900 — these should be there. Look for utility patterns you didn't write — bg-[#...], weird custom names — those are likely phantom utilities from incorrect content config.

Step 3: Audit for dynamic class names.

Search your codebase for patterns like:

TSX
// Bad — dynamic class name, can't be detected
<div className={`bg-${color}-500`}>

// Slightly better — but still risky
const colorClass = `bg-${color}-500`;
return <div className={colorClass}>;

The fix: map the dynamic value to a known set of classes:

TSX
// Good — fully resolvable at build time
const colorClass = {
  red: 'bg-red-500',
  blue: 'bg-blue-500',
  green: 'bg-green-500',
}[color];

return <div className={colorClass}>;

Or use a safelist in v4:

CSS
@source inline("bg-red-{500,600,700}");
@source inline("bg-blue-{500,600,700}");

The mapping approach is more explicit and type-safe; the safelist is more concise. Pick whichever fits your team.

Step 4: Audit plugins.

Each plugin contributes to the bundle. Run:

Bash
npx tailwindcss --show-config

Look at the resolved plugins. For each, ask: "Do It actuallys use this?" If you're using @tailwindcss/typography but never apply the prose class anywhere, you're shipping prose styles for nothing.

In v4, plugins are loaded via @plugin. Remove unused ones:

CSS
/* Before */
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/aspect-ratio";
@plugin "@tailwindcss/container-queries";
@plugin "daisyui";

/* After — keep only what you use */
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/container-queries";

Removing one plugin in a real codebase can save 100-200KB. Even small plugins add up.

Step 5: Check for arbitrary value bloat.

Search for [ in your JSX, which indicates arbitrary values:

Bash
grep -r '\[\d' src/ | wc -l

If you have hundreds of arbitrary values, you're missing tokens. Each top-[13px] and top-[17px] generates separate CSS. Add tokens to your theme:

CSS
@theme {
  --spacing-13: 3.25rem;
  --spacing-17: 4.25rem;
}

Then use top-13 and top-17 instead. The token version shares the same underlying value across all uses, which means better deduplication.

Step 6: Check variants.

In v4, variants are loaded automatically. But you might be using variants that bloat the output:

TSX
// This single class generates hover, focus, active, and disabled variants
<button class="bg-blue-500 hover:bg-blue-600 focus:bg-blue-700 active:bg-blue-800 disabled:opacity-50">

Each state adds a CSS rule. If you have hundreds of utilities each with 4-5 state variants, the cascade can grow.

The fix isn't to remove states — it's to use @apply for repeated patterns:

CSS
@layer components {
  .btn {
    @apply bg-blue-500 hover:bg-blue-600 focus:bg-blue-700 active:bg-blue-800 disabled:opacity-50;
  }
}

This generates one selector (.btn) with multiple states, instead of separate selectors for each variant. The savings compound when you have many similar buttons.

Step 7: Verify the fix.

After making changes, rebuild and check the size:

Bash
pnpm build && ls -lh .next/static/css/

You should see significant reductions. In the case of the teammate's app, the fix sequence was: remove 3 unused plugins (saved 180KB), fix a @source that was missing the CMS output dir (saved 90KB), replace 47 arbitrary values with tokens (saved 60KB), and use @apply for repeated button patterns (saved 35KB). Total: 365KB saved, down from 412KB.

The cleanest pattern is to add bundle size to CI:

YAML
# .github/workflows/bundle-check.yml
- name: Check CSS bundle size
  run: |
    pnpm build
    SIZE=$(stat -c%s .next/static/css/*.css)
    if [ $SIZE -gt 51200 ]; then
      echo "CSS bundle is too large: $SIZE bytes"
      exit 1
    fi

This catches regressions before they hit production.

Common Pitfalls

  1. Not checking bundle size regularly. Make CSS bundle size part of your CI checks. A 5% increase week-over-week adds up fast. Set a budget (e.g., 50KB) and fail the build if it's exceeded.

  2. Adding plugins without measuring impact. Every plugin adds utilities. Measure before and after adding a plugin; remove if the cost isn't justified. A plugin that adds 200KB but you only use 5% of is a 190KB waste.

  3. Hardcoding hex colors instead of using tokens. bg-[#3b82f6] and bg-[#1d4ed8] are two arbitrary values. bg-blue-500 and bg-blue-700 are theme tokens that share the same underlying color scale. Tokens also enable theming and dark mode; arbitrary values don't.

  4. Leaving debug code in production. Dev tools like Storybook often generate extra utilities for their UI. Make sure your production build excludes these. Use separate config files for dev and production builds.

  5. Using safelist as a hammer. @source inline(...) should be precise. If you find yourself listing hundreds of patterns, your dynamic class names need refactoring. The safelist is for edge cases, not a substitute for proper class organization.

  6. Forgetting to enable compression. Even after the above fixes, make sure your CDN or web server is serving CSS with gzip or brotli compression. A 38KB CSS file is 8KB after brotli. Configure your server to send Content-Encoding: br for text files.

  7. Forgetting to clean up unused variants. If your app doesn't use motion-safe: or motion-reduce:, you can disable them in v4's @theme to skip generating those variants. Most teams have variants they don't realize they're shipping.

When to Use This (And When Not To)

Audit your bundle if it's over 50KB, your Lighthouse score is dropping, or your team has been growing without paying attention to CSS. The fixes are mechanical and don't require redesigning anything. A 400KB CSS bundle is almost always fixable in a day.

If you're under 50KB and your performance is fine, don't optimize prematurely. The risk of breaking things outweighs the small bundle savings. Focus on shipping features.

For new projects, follow the patterns from day one: declare tokens, avoid arbitrary values, install only needed plugins. Prevention is cheaper than remediation. Set up a CI bundle check before you launch, and your bundle size will stay manageable.

Wrapping Up

A bloated Tailwind bundle is almost always caused by one of six patterns: missing content config, dynamic class names, plugin bloat, arbitrary values, unused variants, or duplication. Each has a clear fix, and the cumulative savings are dramatic. Run a bundle audit today — measure your CSS size, identify the worst offenders, and apply the fixes. You'll likely see a 50-90% reduction in CSS size with no visual changes. Add a CI check that fails if your CSS exceeds a budget, and you'll never have to debug this issue again.

Setting Up Bundle Monitoring

To prevent this from happening again, set up monitoring. Here's a minimal GitHub Action that fails if your CSS exceeds a budget:

YAML
# .github/workflows/bundle-size.yml
name: Bundle size check
on: [pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npm run build
      - name: Check CSS size
        run: |
          SIZE=$(stat -c%s .next/static/css/*.css 2>/dev/null || stat -c%s dist/assets/*.css)
          LIMIT=51200  # 50KB
          if [ $SIZE -gt $LIMIT ]; then
            echo "::error::CSS bundle is ${SIZE} bytes, limit is ${LIMIT}"
            exit 1
          fi

This catches regressions on every PR. Set the limit based on your app's needs — 50KB is a reasonable budget for a typical app.

You can also use tools like bundlewatch or size-limit for more sophisticated tracking:

JSON
// package.json
{
  "size-limit": [
    {
      "path": ".next/static/css/*.css",
      "limit": "50 KB"
    }
  ]
}

Run npx size-limit to check current sizes against your budget.

A Real Audit Walkthrough

The following walks through what a typical bundle audit looks like, with concrete commands you can run. It will use a hypothetical Next.js app as the example.

Bash
# Build the app
pnpm build

# Find your CSS files
ls -lh .next/static/css/

# Sort utilities by usage frequency
grep -oh '\b[a-z-]\+:\?[a-z0-9-]\+' .next/static/css/*.css | sort | uniq -c | sort -rn | head -50

# Look for duplicates (sign of multiple @source directories)
grep -c '\.card' .next/static/css/*.css

These commands give you a starting point. From here, you can identify what's actually in your CSS and where the bloat comes from.

For a deeper analysis, use PurgeCSS or csso:

Bash
# Check what's actually being used vs. what's in the bundle
npx purgecss --css .next/static/css/*.css --content 'src/**/*.{js,jsx,ts,tsx}' --output purged.css
wc -c .next/static/css/*.css purged.css

The difference between the two file sizes tells you how much dead CSS is in your bundle.

The Long-Term Fix: Process, Not Tools

Tools and CI checks help, but the long-term fix is process. Establish these habits on your team:

  1. Code review for CSS patterns. When someone adds a new plugin or arbitrary value, ask why. Push back if there's not a good reason.

  2. Quarterly bundle audits. Once per quarter, run a full bundle analysis. Look for things that have grown without explanation.

  3. Document your conventions. Write down what patterns you use and why. New team members should know to prefer tokens over arbitrary values, theme variants over custom ones, etc.

  4. Track CSS bundle size in your dashboards. Visibility matters. If your team's dashboard shows CSS bundle size trending up over time, you'll catch issues before they become emergencies.

A Case Study: From 412KB to 38KB

The following walks through a real bundle reduction It last quarter because the steps are illuminating. The app was a SaaS dashboard with a marketing site bolted on. The CSS bundle had grown to 412KB over two years of feature development. Nobody had checked the size in over a year. The team was getting complaints about slow first-paint on mobile.

The first thing It was run the build and check the file:

Bash
pnpm build
ls -lh dist/assets/
# -rw-r--r-- 1 user user 412K  main.5f3a.css

Then It lookeds at the top 20 utilities by frequency:

Bash
grep -ohE '\.[a-zA-Z][a-zA-Z0-9_-]*' dist/assets/main.*.css | sort | uniq -c | sort -rn | head -20

The output revealed two surprises. First, there were 47 different bg-[#...] arbitrary values. Each one was generating a separate CSS rule. Second, @tailwindcss/typography was generating 280KB of prose styles — but the team only had one blog post and one marketing page using prose.

The fixes:

  1. Replaced 47 arbitrary values with theme tokens (saved 60KB). Most were slight variations of the same color. Adding --color-brand-{50,100,200,...,900} to @theme and using bg-brand-500 collapsed the duplicates.

  2. Removed the typography plugin (saved 180KB). The team kept one blog post and one marketing page; we inlined the styles we actually needed using @utility blocks.

  3. Fixed a missing @source declaration (saved 90KB). A CMS export directory wasn't being scanned, so the engine was generating utilities for classes that didn't exist in any reachable code path. Removing that @source from a wrong path and adding it to the correct path cut the bundle substantially.

  4. Used @apply for repeated button patterns (saved 35KB). The app had 80+ buttons across 40 components, each one with the same hover/focus/disabled chain. Replacing those with a .btn class via @apply deduplicated the variant cascade.

  5. Enabled brotli compression at the CDN (saved another 70% on transfer). This isn't a Tailwind fix, but it's the kind of thing teams forget. The 38KB file transfers as 8KB after brotli.

After all of this, the final CSS was 38KB — about 9% of the original. Page load on a mid-range Android device dropped from 4.2 seconds to 1.8 seconds. The team was ecstatic. The fixes took about six hours over two days.

The lesson: bundle bloat is almost always a configuration drift that accumulated over time. The fixes are mechanical and don't require redesigning anything. You just need to know what to look for.

What About CSS-in-JS Bundle Bloat?

If you're using a CSS-in-JS library instead of Tailwind, similar principles apply but the failure modes are different. Runtime CSS-in-JS (styled-components, Emotion) bundles the runtime in your JS, so your JS bundle grows. Build-time CSS-in-JS (vanilla-extract, Panda) emits static CSS that can be analyzed similarly to Tailwind.

For runtime CSS-in-JS, the bundle bloat usually comes from: large theme objects, unused styled components (because they're imported but not rendered), and the runtime itself. For build-time CSS-in-JS, the bundle bloat comes from: dynamic styles generated for every prop combination, unused variants, and large token systems.

The diagnostic steps are similar: inspect the bundle, identify unused styles, remove them. The tooling is different (you'll use webpack-bundle-analyzer or source-map-explorer instead of CSS-specific tools), but the principles are universal. Less is more, and unused code is a tax on every user.

Further Reading

Hermes Smith

Comments (0)

Sign in to join the conversation.

No comments yet. Be the first to share your thoughts!