CSS bundle size affects everything downstream: First Contentful Paint, Largest Contentful Paint, mobile data usage, perceived speed. Sites shipping 200+ KB of CSS are throwing performance away. The fix combines four techniques: remove unused CSS, split per route, compress with Brotli, cache aggressively. Done together, typical sites cut CSS to 30-60 KB compressed per page.
webpack-bundle-analyzer. For Vite: rollup-plugin-visualizer. For Next.js: ANALYZE=true next build. Shows what's inside each bundle, where the bytes go.
Covered fully in the unused CSS guide. Most impactful step — typically 80% reduction. Don't move on to other techniques until you've done this.
Automatic with CSS Modules and styled-jsx. Each page's CSS imports compile into per-route chunks. No config needed.
// vite.config.js
export default {
build: {
cssCodeSplit: true, // default in Vite 4+
rollupOptions: {
output: {
manualChunks: {
'vendor-css': ['react', 'react-dom'],
}
}
}
}
}
// webpack.config.js with MiniCssExtractPlugin
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
styles: {
name: 'styles',
type: 'css/mini-extract',
chunks: 'async', /* async to enable splitting */
enforce: true,
}
}
}
}
}
// tailwind.config.js
module.exports = {
content: [
'./src/**/*.{html,js,jsx,ts,tsx,vue}',
'./public/**/*.html',
// EVERY file that uses Tailwind classes
],
}
Tailwind only generates classes it finds in content. Missing paths = missing classes. Don't use wildcards too broadly — they slow build but don't add to bundle.
// scss/main.scss @use 'bootstrap/scss/functions'; @use 'bootstrap/scss/variables'; @use 'bootstrap/scss/mixins'; @use 'bootstrap/scss/root'; @use 'bootstrap/scss/reboot'; @use 'bootstrap/scss/type'; @use 'bootstrap/scss/grid'; @use 'bootstrap/scss/buttons'; // Skip components you don't use: carousel, modal, etc.
@use 'bulma/sass/utilities/all'; @use 'bulma/sass/base/minireset'; @use 'bulma/sass/elements/button'; @use 'bulma/sass/components/navbar'; // Cherry-pick only what you use
Most build tools minify CSS by default. Verify you're using cssnano or equivalent.
// postcss.config.js
module.exports = {
plugins: [
require('cssnano')({
preset: ['default', {
discardComments: { removeAll: true },
minifyFontValues: true,
normalizeWhitespace: true,
colormin: true,
}]
})
]
}
Brotli compresses CSS 15-25% smaller than gzip. All modern browsers support it.
http {
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json
application/javascript text/xml
application/xml application/xml+rss text/javascript;
# Also enable gzip as fallback
gzip on;
gzip_types same list as above;
}
<IfModule mod_brotli.c> AddOutputFilterByType BROTLI_COMPRESS text/css application/javascript </IfModule> <IfModule mod_deflate.c> AddOutputFilterByType DEFLATE text/css application/javascript </IfModule>
Most CDNs apply Brotli automatically when origin doesn't. Check CDN dashboard to confirm.
// Build step: pre-compress CSS files
npm install --save-dev compression-webpack-plugin
new CompressionPlugin({
algorithm: 'brotliCompress',
test: /\.(css|js)$/,
filename: '[path][base].br',
});
Then configure nginx to serve .br files when Accept-Encoding includes brotli:
brotli_static on;
Production builds output filenames like main.abc123.css. The hash changes when content changes, so you can cache forever:
# nginx
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
Repeat visits hit cache: zero download cost. First visit pays the full price. Hashed filenames mean cache busts automatically when CSS changes.