Slimming Down Your Bundle: Removing Unused CSS with PurgeCSS
We've all been there. You start a project with a robust CSS framework—maybe Bootstrap, Bulma, or a customized utility library. You build your landing page, launch it, and then run a Lighthouse audit.
Performance Score: 65. Reduce unused CSS: Potential savings 140 KiB.
Your user is downloading the entire framework—buttons you aren't using, grid classes you don't need, and utility helpers for scenarios that don't exist in your app. This isn't just about bytes; it's about parsing time. The browser has to read, parse, and construct the CSSOM for every rule, even the ones that apply to nothing.
Enter PurgeCSS. While the concept is simple ("delete unused styles"), the implementation in a complex, dynamic application is nuanced.
In this deep dive, we'll go beyond the basics. We'll look at how PurgeCSS actually works (AST analysis), how to handle dynamic class names safely, and how to integrate it into a production CI/CD pipeline.
The Problem: Append-Only CSS
CSS is notoriously difficult to maintain. It is an append-only language. "I'll add this class for the new feature." "I'll override this class for the mobile view." * "I'll add !important because I can't figure out why this isn't working."
Developers rarely delete CSS because they are afraid. "Does this class `.container-fluid` break the About page that no one has touched in 2 years?" It's safer to leave it. Over 5 years, this accumulates into megabytes of dead code.
How PurgeCSS Works: The Extractor Pattern
PurgeCSS is, at its core, a matching engine.
- Input: It takes your CSS files and your content files (HTML, JS, PHP, Vue, etc.).
- Extraction: It runs an "extractor" on the content files. An extractor is a function that reads text and returns a list of strings that look like CSS classes.
- AST Analysis: It parses your CSS into an Abstract Syntax Tree (AST).
- Tree Shaking: It walks the CSS AST. For every rule (e.g.,
.btn-primary { ... }), it checks ifbtn-primaryexists in the list of extracted strings. - Output: If it finds a match, the rule is kept. If not, the node is removed from the AST. The final AST is serialized back to CSS.
The Default Extractor
The default extractor is extremely naive (but effective). It roughly matches this Regex:
/[A-Za-z0-9_-]+/gIt doesn't understand HTML. It doesn't understand JavaScript. It just finds words. If you have a JS file with:
const myVar = "container";PurgeCSS extracts "const", "myVar", and "container". It then checks if you have a CSS class named .const. Probably not. But if you have a class named .container, it keeps it.
This "dumb" approach is a feature, not a bug. It means PurgeCSS works with any file format—Python templates, Ruby ERB, Rust source code—without needing specific parsers for each language.
Configuring for Production
While modern frameworks like Tailwind CSS v3 have JIT (Just-In-Time) engines that generate CSS on the fly, PurgeCSS is still critical for: Legacy projects (Bootstrap/Foundation). Projects using heavy third-party UI libraries (e.g., a massive datepicker stylesheet). * Custom CSS architectures.
1. Basic Setup (PostCSS)
In a modern stack (Next.js, Vite, Webpack), you should run PurgeCSS as a PostCSS plugin. This ensures it runs after your preprocessors (Sass/Less) and before your minifiers (cssnano).
npm install --save-dev @fullhuman/postcss-purgecsspostcss.config.js:
const purgecss = require('@fullhuman/postcss-purgecss')
module.exports = {
plugins: [
// ... other plugins like tailwindcss or autoprefixer
process.env.NODE_ENV === 'production' && purgecss({
content: ['./src/**/*.{js,jsx,ts,tsx,html}'],
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
safelist: {
standard: ['html', 'body'],
deep: [/^markdown-content/], // Keep children of .markdown-content
}
})
]
}Critical Note: Only run PurgeCSS in production. In development, you want all classes available so you can experiment in DevTools.
2. The "Dynamic Class" Pitfall
The number one reason developers break their sites with PurgeCSS is dynamic string concatenation.
This WILL fail:
// React Component
const Button = ({ color }) => {
// color is "red" or "blue" passed as prop
const className = `btn-${color}`;
return <button className={className}>Click Me</button>;
};PurgeCSS analyzes the file statically. It sees the string "btn-" and the string "color". It does not execute the code. It never sees "btn-red". Therefore, it strips .btn-red from your CSS.
The Fix: Always map full class names.
const BUTTON_STYLES = {
red: 'btn-red',
blue: 'btn-blue',
};
const Button = ({ color }) => {
return <button className={BUTTON_STYLES[color]}>Click Me</button>;
};Now PurgeCSS sees the literal strings 'btn-red' and 'btn-blue' in the source file and preserves them.
3. Safelisting Strategies
Sometimes you can't control the HTML source (e.g., content coming from a CMS, or HTML injected via dangerouslySetInnerHTML).
For these cases, you need safelist.
String Match: safelist: ['random-class']
Regex Match: safelist: [/^nav-/] (Keeps .nav-link, .nav-item, etc.)
Deep Match (The Saver): If you have a blog where user content is injected into a container:
<div class="blog-content">
<h1>User Content</h1>
<p>Some text...</p>
</div>You can't know what tags are inside. Use the deep or greedy option to keep children:
safelist: {
deep: [/blog-content/] // Keeps any selector starting with .blog-content (e.g., .blog-content h1)
}CSS Modules vs. PurgeCSS
If you use CSS Modules (styles.module.css), you might think you don't need PurgeCSS. Theory: CSS Modules scope classes locally, so you only import what you use. Reality: CSS Modules hash class names to avoid collisions, but they don't automatically remove unused rules from the bundled file. If you define a class in the module and never use it in the JS, it's still in the CSS bundle.
PurgeCSS works perfectly with CSS Modules, effectively cleaning up the dead code within your modules.
Advanced: Extracting from Third-Party Libraries
A common bloat source is node_modules. You import a library like react-datepicker, and it comes with a 40KB CSS file.
You can configure PurgeCSS to scan that specific node module:
purgecss({
content: [
'./src/**/*.{js,ts,jsx,tsx}',
'./node_modules/react-datepicker/dist/**/*.js' // Scan the lib's JS to see what classes it adds
],
css: ['./node_modules/react-datepicker/dist/react-datepicker.css']
})This is risky if the library adds classes dynamically, but highly effective if the library is well-structured.
Case Study: The 91% Reduction
I recently optimized a marketing site built on a customized Bootstrap 4 theme. Before: 168KB (minified) After: 15KB (minified)
The site load time on 3G mobile dropped from 3.2s to 1.1s. The "First Contentful Paint" (FCP) improved by 40%.
Why? Because the browser didn't have to download 150KB of unused junk. More importantly, it didn't have to parse 4000 unused CSS rules to build the render tree.
Conclusion
In 2026, shipping unused CSS is professional negligence. Users are on flaky mobile connections, and every kilobyte of render-blocking CSS delays the moment they can use your app.
Tools like Tailwind JIT have popularized the "generate only what you need" approach, but for everything else, PurgeCSS is the standard. Configure it correctly, watch out for dynamic classes, and enjoy the speed.