Tailwind CSS 74 🎨 CI/CD Unused Class Auditing and CSS Bundle Size Budgeting
A CSS bundle grows. The feature ships, the class is added, the feature is removed, and the class remains. The @apply rules accumulate, the custom utilities persist, and the design system evolves. The bundle that was fifty kilobytes at the launch is two hundred kilobytes a year later, and no one noticed because the growth was gradual and the build never failed. The CI/CD auditing and the size budgeting are the mechanisms that make the growth visible, the regression caught, and the size the enforced. This chapter covers the tools, the budgets, and the pipeline.
Key point: Tailwind’s JIT engine already removes the unused utilities. The class that is not in the source is not in the output. The unused-class auditing is about the classes the engine cannot see: the dynamic constructions, the @apply rules, the custom utilities that are no longer referenced, and the safelist entries that outlived their purpose.
Why the auditing and the budgeting matter
The gradual-growth problem. The bundle grows a few kilobytes per feature. The individual change is invisible, and the cumulative growth is the problem. The budget is the threshold, and the CI fails when the budget is exceeded.
The unused-class problem. The Tailwind JIT generates the CSS for the candidates it finds. The candidate that is no longer in the source is no longer in the output. The @apply rule that references the removed utility remains until the rule is removed.
The @apply problem. The @apply inlines the utility into the custom CSS. The rule is not the candidate, and the engine does not track it. The @apply that references the removed utility produces the error, and the @apply that is no longer used produces the dead CSS.
The custom-utility problem. The @utility registers the custom utility. The utility that is no longer referenced in the markup is the dead CSS, and the engine generates it because the @utility is the registration.
The safelist problem. The @source inline(...) and the legacy safelist preserve the class. The entry that was the workaround for the dynamic class remains after the dynamic class is refactored. The dead CSS.
The dependency problem. The design system package ships the classes. The class that is the consumer’s usage is the bundle. The audit is the cross-repository.
The budget problem. The budget is the number in the CI. The pull request that pushes the bundle over the budget fails. The author must justify the increase or the optimize the change.
a. The unused-class auditing
The audit finds the classes the engine generated but the application no longer uses.
The Tailwind’s built-in reporting
The v4 engine does not report the unused classes by default. The class that is not in the source is not generated. The audit is the reverse: the class that is generated but not used.
The class-usage report
The plugin that reports the class usage. The tailwindcss-classnames and the similar tools produce the report.
npx tailwindcss-classnames --input src --output report.json
The report lists the classes and the usage counts.
The @apply audit
The @apply rules are the source of the dead CSS. The audit lists the rules and the utilities they reference.
# Find the @apply rules
grep -r "@apply" src/
# The output
src/components/Button.css:.btn { @apply bg-blue-600 px-4; }
src/components/Card.css:.card { @apply rounded-lg shadow-md; }
The rule that is no longer used is the candidate for the removal.
The custom-utility audit
The @utility registrations are the custom utilities. The audit lists the utilities and the markup usage.
# Find the @utility registrations
grep -r "@utility" src/
# Find the usage in the markup
grep -r "tab-4" src/
The utility that is not in the markup is the dead CSS.
The safelist audit
The @source inline(...) and the safelist are the preserved classes. The audit lists the entries and the reason.
# Find the safelist entries
grep -r "@source inline" src/
The entry that is no longer needed is the removal candidate.
The audit table
| The audit | The tool |
|---|---|
| The class usage | The tailwindcss-classnames |
The @apply | The grep |
The @utility | The grep |
| The safelist | The grep |
| The dependency | The cross-repo |
b. The CSS bundle size budgeting
The budget is the threshold the CI enforces. The bundle that exceeds the budget fails the build.
The bundle size measurement
The build produces the CSS file, and the size is the measurement.
# The build
npm run build
# The size
ls -lh dist/assets/*.css
# -rw-r--r-- 1 user user 48K Jan 15 10:30 dist/assets/index.a1b2c3d4.css
The size is the raw, and the compression is the separate.
The compression measurement
The gzip and the brotli sizes are the transfer size.
# The gzip
gzip -c dist/assets/index.a1b2c3d4.css | wc -c
# 12288
# The brotli
brotli -c dist/assets/index.a1b2c3d4.css | wc -c
# 10240
The transfer size is the number that matters for the user.
The size-limit
The size-limit is the popular tool. It measures the build and the fails when the budget is exceeded.
// package.json
{
"size-limit": [
{
"path": "dist/assets/*.css",
"limit": "50 KB",
"gzip": true
},
{
"path": "dist/assets/*.js",
"limit": "200 KB",
"gzip": true
}
]
}
The path is the glob, the limit is the threshold, and the gzip is the compression.
The size-limit command
npx size-limit
The command measures and the reports. The exit code is the non-zero when the budget is exceeded.
The bundlewatch
The bundlewatch is the similar tool with the GitHub integration.
// bundlewatch.config.json
{
"files": [
{
"path": "./dist/assets/*.css",
"maxSize": "50 kB"
}
]
}
The bundlewatch compares with the base branch and the comments on the pull request.
The CI integration
# .github/workflows/size.yml
name: Size
on: [pull_request]
jobs:
size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- run: npx size-limit
The workflow runs on the pull request, and the failure blocks the merge.
The budget table
| The tool | The purpose |
|---|---|
size-limit | The local budget |
bundlewatch | The GitHub integration |
| The gzip | The transfer size |
| The brotli | The transfer size |
| The CI workflow | The enforcement |
c. The CI/CD pipeline
The pipeline combines the audit, the build, and the budget check.
The full pipeline
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run test
- run: npm run build
- run: npx size-limit
The lint, the test, the build, and the size-limit run in the order.
The audit step
- name: Audit the unused classes
run: |
npx tailwindcss-classnames --input src --output report.json
npx ts-node scripts/audit-unused.ts
The audit produces the report, and the script checks the thresholds.
The custom audit script
// scripts/audit-unused.ts
import { readFileSync } from 'fs';
const report = JSON.parse(readFileSync('report.json', 'utf-8'));
const unused = report.filter((c) => c.usage === 0);
if (unused.length > 0) {
console.error('Unused classes:', unused);
process.exit(1);
}
The script reads the report and the fails when the unused exist.
The budget check step
- name: Check the CSS budget
run: |
CSS_SIZE=$(gzip -c dist/assets/*.css | wc -c)
BUDGET=51200
if [ "$CSS_SIZE" -gt "$BUDGET" ]; then
echo "CSS exceeds the budget: $CSS_SIZE > $BUDGET"
exit 1
fi
The step measures the gzip size and the compares with the budget.
The pull request comment
- name: Comment the size
uses: actions/github-script@v7
with:
script: |
const size = require('fs').statSync('dist/assets/index.css').size;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `CSS size: ${size} bytes`,
});
The step comments the size on the pull request.
The pipeline table
| The step | The purpose |
|---|---|
| The lint | The code style |
| The test | The correctness |
| The build | The output |
| The audit | The unused classes |
| The size-limit | The budget |
| The comment | The visibility |
Complete Example Session
// ============================================
// PART 1: THE SIZE-LIMIT CONFIG
// ============================================
// package.json
{
"size-limit": [
{
"path": "dist/assets/*.css",
"limit": "50 KB",
"gzip": true
}
]
}
// ============================================
// PART 2: THE BUNDLEWATCH CONFIG
// ============================================
// bundlewatch.config.json
{
"files": [
{
"path": "./dist/assets/*.css",
"maxSize": "50 kB"
}
]
}
// ============================================
// PART 3: THE FULL CI WORKFLOW
// ============================================
// .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run test
- run: npm run build
- run: npx size-limit
// ============================================
// PART 4: THE AUDIT STEP
// ============================================
- name: Audit the unused classes
run: |
npx tailwindcss-classnames --input src --output report.json
npx ts-node scripts/audit-unused.ts
// ============================================
// PART 5: THE AUDIT SCRIPT
// ============================================
// scripts/audit-unused.ts
import { readFileSync } from 'fs';
const report = JSON.parse(readFileSync('report.json', 'utf-8'));
const unused = report.filter((c) => c.usage === 0);
if (unused.length > 0) {
console.error('Unused classes:', unused);
process.exit(1);
}
// ============================================
// PART 6: THE BUDGET CHECK
// ============================================
- name: Check the CSS budget
run: |
CSS_SIZE=$(gzip -c dist/assets/*.css | wc -c)
BUDGET=51200
if [ "$CSS_SIZE" -gt "$BUDGET" ]; then
echo "CSS exceeds the budget"
exit 1
fi
// ============================================
// PART 7: THE PR COMMENT
// ============================================
- name: Comment the size
uses: actions/github-script@v7
with:
script: |
const size = require('fs').statSync('dist/assets/index.css').size;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `CSS size: ${size} bytes`,
});
// ============================================
// PART 8: THE @APPLY AUDIT
// ============================================
# Find the @apply rules
grep -r "@apply" src/
# Find the usage
grep -r "btn" src/components/
// ============================================
// PART 9: THE CUSTOM UTILITY AUDIT
// ============================================
# Find the @utility registrations
grep -r "@utility" src/
# Find the usage
grep -r "tab-4" src/
// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================
// 1. The JIT removes the unused utilities.
// 2. The @apply and the @utility need the audit.
// 3. The safelist needs the periodic review.
// 4. The size-limit enforces the budget.
// 5. The bundlewatch comments on the PR.
// 6. The CI runs the audit and the budget.
// 7. The failure blocks the merge.
// 8. The comment makes the size visible.
// 9. The budget is the number in the config.
// 10. The gradual growth is the caught.
The ten parts showed the size-limit config, the bundlewatch config, the full CI workflow, the audit step, the audit script, the budget check, the PR comment, the @apply audit, the custom utility audit, and the complete pattern.
Quick Reference
The Budget Tools
| The tool | The purpose |
|---|---|
size-limit | The local budget |
bundlewatch | The GitHub integration |
| The custom script | The audit |
| The CI workflow | The enforcement |
The Audit Targets
| The target | The tool |
|---|---|
| The class usage | The tailwindcss-classnames |
The @apply | The grep |
The @utility | The grep |
| The safelist | The grep |
The Measurements
| The measurement | The purpose |
|---|---|
| The raw size | The local |
| The gzip | The transfer |
| The brotli | The transfer |
| The budget | The threshold |
The CI Steps
| The step | The purpose |
|---|---|
| The lint | The code style |
| The test | The correctness |
| The build | The output |
| The audit | The unused |
| The size-limit | The budget |
| The comment | The visibility |
The Budget Config
| The field | The purpose |
|---|---|
path | The glob |
limit | The threshold |
gzip | The compression |
maxSize | The bundlewatch |
Best Practices
✅ Do This:
// Use the size-limit.
{
"size-limit": [
{ "path": "dist/assets/*.css", "limit": "50 KB", "gzip": true }
]
} // ✅
# Run the size-limit in the CI.
- run: npx size-limit // ✅
# Comment the size on the PR.
- uses: actions/github-script@v7 // ✅
# Audit the @apply rules.
grep -r "@apply" src/ // ✅
# Audit the custom utilities.
grep -r "@utility" src/ // ✅
// Fail the CI on the unused.
if (unused.length > 0) process.exit(1); // ✅
❌ Don’t Do This:
# Don't skip the audit.
- run: npm run build // ❌ No budget
// Don't set the budget too high.
{ "limit": "1 MB" } // ❌ The budget is the point
# Don't leave the dead @apply.
.btn { @apply bg-blue-600; } // ❌ The btn is unused
# Don't leave the safelist forever.
safelist: ['text-red-600'] // ❌ The reason is gone
# Don't fail the build without the message.
exit 1 // ❌ The author does not know why
// Don't measure the raw size only.
const size = statSync('dist/index.css').size; // ⚠️ The gzip matters
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The budget exceeded | The growth | Optimize or justify |
| The audit fails | The unused classes | Remove them |
The @apply dead | The rule unused | Remove the rule |
| The safelist stale | The reason gone | Remove the entry |
| The CI slow | The full build | Cache the deps |
| The comment missing | No script | Add the script |
| The measurement wrong | The raw size | Measure the gzip |
Real-World Examples
1. The size-limit Config
{ "size-limit": [{ "path": "dist/*.css", "limit": "50 KB", "gzip": true }] }
2. The bundlewatch Config
{ "files": [{ "path": "./dist/*.css", "maxSize": "50 kB" }] }
3. The CI Workflow
- run: npm run build
- run: npx size-limit
4. The Audit Script
const unused = report.filter((c) => c.usage === 0);
if (unused.length) process.exit(1);
5. The Budget Check
CSS_SIZE=$(gzip -c dist/*.css | wc -c)
6. The PR Comment
- uses: actions/github-script@v7
7. The @apply Audit
grep -r "@apply" src/
8. The Custom Utility Audit
grep -r "@utility" src/
9. The Safelist Audit
grep -r "@source inline" src/
10. The Complete Pattern
- run: npm run build
- run: npx size-limit
Visual
The CI Pipeline
┌────────────────────────────────────────────────────────────┐
│ THE CI PIPELINE │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ THE LINT │ │
│ │ The eslint, the prettier, the stylelint. │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ THE TEST │ │
│ │ The unit, the integration. │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ THE BUILD │ │
│ │ The vite, the tailwind. │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ THE AUDIT │ │
│ │ The unused classes. │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ THE SIZE-LIMIT │ │
│ │ The budget check. │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ THE COMMENT │ │
│ │ The PR comment. │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The Budget
┌────────────────────────────────────────────────────────────┐
│ THE BUDGET │
│ │
│ THE CONFIG │
│ ┌──────────────────────────────────────────────────┐ │
│ │ size-limit: [ │ │
│ │ { "path": "dist/*.css", │ │
│ │ "limit": "50 KB", │ │
│ │ "gzip": true } │ │
│ │ ] │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ THE MEASUREMENT │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The raw: 180 KB │ │
│ │ The gzip: 42 KB │ │
│ │ The brotli: 38 KB │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ THE CHECK │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 42 KB < 50 KB → the pass │ │
│ │ 55 KB > 50 KB → the fail │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The Audit Targets
┌────────────────────────────────────────────────────────────┐
│ THE AUDIT TARGETS │
│ │
│ THE @apply │
│ ┌──────────────────────────────────────────────────┐ │
│ │ .btn { @apply bg-blue-600 px-4; } │ │
│ │ → the btn is used? │ │
│ │ → the grep the usage │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ THE @utility │
│ ┌──────────────────────────────────────────────────┐ │
│ │ @utility tab-4 { tab-size: 4; } │ │
│ │ → the tab-4 is used? │ │
│ │ → the grep the markup │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ THE SAFELIST │
│ ┌──────────────────────────────────────────────────┐ │
│ │ @source inline("text-red-600"); │ │
│ │ → the reason still valid? │ │
│ │ → the review the entry │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The Gradual Growth
┌────────────────────────────────────────────────────────────┐
│ THE GRADUAL GROWTH │
│ │
│ THE LAUNCH │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The CSS: 48 KB │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ THE 6 MONTHS │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The CSS: 62 KB │ │
│ │ → the budget check: the pass (62 < 100) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ THE 12 MONTHS │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The CSS: 95 KB │ │
│ │ → the budget check: the pass (95 < 100) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ THE 18 MONTHS │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The CSS: 120 KB │ │
│ │ → the budget check: the FAIL │ │
│ │ → the author must justify or optimize │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| The JIT | The removes the unused utilities |
The @apply | The audit the rule |
The @utility | The audit the custom |
| The safelist | The periodic review |
size-limit | The budget tool |
bundlewatch | The GitHub integration |
| The gzip size | The transfer measurement |
| The budget | The threshold |
| The CI workflow | The enforcement |
| The PR comment | The visibility |
Key takeaways:
- The Tailwind JIT already removes the unused utilities. The class that is not in the source is not in the output. The audit is about the classes the engine cannot see: the
@apply, the@utility, and the safelist. - The
@applyrules are the source of the dead CSS. The rule that is no longer used remains until the removal. Thegrepfinds the rules, and the usage check finds the dead. - The
@utilityregistrations are the custom utilities. The engine generates the utility because the@utilityregisters it. The markup usage check finds the dead. - The safelist needs the periodic review. The entry that was the workaround remains after the refactor. The reason should be the documented, and the entry removed when the reason is gone.
- The
size-limitis the budget tool. The config lists the paths, the limits, and the compression. The CI runs the tool, and the failure blocks the merge. - The
bundlewatchadds the GitHub integration. The tool compares with the base branch and the comments on the pull request. The size change is the visible. - The gzip size is the transfer measurement. The raw size is the local, and the gzip and the brotli are the transfer. The budget should be on the transfer size.
- The gradual growth is the caught. The budget is the threshold, and the CI fails when it is exceeded. The author must justify the increase or the optimize the change.
Remember: The unused-class auditing and the CSS bundle size budgeting are the CI/CD discipline. The Tailwind JIT removes the unused utilities, and the audit targets the @apply, the @utility, and the safelist. The size-limit enforces the budget, and the bundlewatch makes the size visible on the pull request. The result is the bundle that stays within the threshold, the regression caught at the review, and the gradual growth the addressed.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!