If your Lighthouse score is stuck in the 60s and the "Eliminate render-blocking resources" warning keeps showing up, your CSS is the problem. Not the amount of CSS — the timing of it. Your browser won’t paint a single pixel until it has downloaded, parsed, and applied every stylesheet in <head>. That’s the deal, and it’s been killing page performance since the early 2000s.
The fix is well-known: inline the CSS needed for above-the-fold content directly into the HTML, then load the rest asynchronously. The problem is doing it automatically across hundreds of routes without going insane. That’s where critters and its community successor beasties come in.
The Problem in Plain Terms
When a browser fetches your HTML and encounters <link rel="stylesheet" href="/assets/main.css">, it stops rendering. It fetches the CSS, parses it, builds the CSSOM, and only then starts painting. On a fast connection this might cost 200ms. On a mobile connection with latency? Easy to burn a full second there. That’s your LCP tanking before a single byte of your JavaScript has even been considered.
The traditional workaround — splitting CSS into "critical" and "non-critical" chunks manually — is tedious, error-prone, and breaks constantly as you add new components. Nobody does it by hand in 2026.
Critical CSS extraction tools automate this: they simulate rendering your page, figure out which CSS rules actually affect visible content, inline those rules into <style> tags in the HTML, and defer the full stylesheet to load after paint.
Critters: The Original Tool
critters was created by the Google Chrome team. GitHub: https://github.com/GoogleChromeLabs/critters
The core idea is elegant: instead of running a real browser to determine which CSS applies above the fold, critters does a fast approximation using the HTML structure. It walks the stylesheet, checks each selector against the document’s DOM, and inlines any rule that matches. No headless Chrome, no puppeteer, no 30-second build times.
It shipped as a Webpack plugin and worked well enough that the Angular CLI team baked it directly into ng build --prod.
Then Google, being Google, eventually deprioritized maintenance.
Enter Beasties
beasties is the community-maintained fork that picked up where critters left off. The Angular CLI switched to it internally. The API is intentionally compatible with critters — same options, same plugin interface — so migration is usually a one-line change.
NPM package: beasties
GitHub: https://github.com/danielroe/beasties
If you’re starting a new project, use beasties. If you’re on an existing project using critters, upgrade to beasties. The rest of this article covers both, since the options are nearly identical.
How It Works Under the Hood
Understanding this prevents a lot of confusion about what the tool can and can’t do.
- beasties receives your compiled HTML and the path to your CSS assets.
- It parses the HTML into a lightweight DOM (no full browser engine).
- It walks every rule in your stylesheet and checks whether the selector matches any element in that DOM.
- Matching rules get inlined into a
<style>block in<head>. - The original
<link>stylesheet gets transformed to load asynchronously:<link rel="preload" as="style" onload="this.rel='stylesheet'">with a<noscript>fallback.
The key limitation: it can only match CSS to the static HTML it receives. Dynamic content rendered client-side by JavaScript is invisible to this process. This is usually fine — your above-the-fold shell is typically server-rendered or static anyway. But keep it in mind.
Webpack Setup
Install:
npm install --save-dev beasties-webpack-plugin
# or if still on critters:
npm install --save-dev critters-webpack-plugin
In your webpack.config.js:
const Beasties = require('beasties-webpack-plugin');
module.exports = {
plugins: [
new Beasties({
// Load the deferred stylesheet via <link rel="preload">
preload: 'swap',
// Remove inlined rules from the external stylesheet to avoid double-loading
pruneSource: true,
// Inline font declarations found in critical CSS
inlineFonts: false,
// How to handle @font-face: 'swap' defers, 'critical' inlines
fontDisplay: 'swap',
// Minimum size of stylesheet to bother with (bytes)
// Avoids inlining tiny stylesheets that aren't worth the overhead
minimumExternalSize: 4096,
// Log level: 'info', 'warn', 'error', 'trace', 'silent'
logLevel: 'warn',
}),
],
};
One thing to understand about pruneSource: when enabled, beasties removes the inlined rules from the external CSS file. This means the external file is smaller (faster to load), but if it fails to load for any reason, those styles are gone. In practice, for production with proper CDN caching, this is fine. If you’re doing anything exotic with CSS reloading or dynamic theme switching, leave it off.
Vite Setup
Vite doesn’t have an official beasties plugin in its ecosystem yet, but you can invoke beasties directly in a Vite plugin using the transformIndexHtml hook. This is the approach that actually works reliably:
// vite.config.js
import { defineConfig } from 'vite';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import Beasties from 'beasties';
function criticalCssPlugin() {
return {
name: 'critical-css',
apply: 'build', // Only runs during `vite build`, not dev server
async transformIndexHtml(html, ctx) {
// Only process after the full bundle is written
if (!ctx.bundle) return html;
const beasties = new Beasties({
path: resolve('./dist'),
publicPath: '/',
pruneSource: true,
preload: 'swap',
logLevel: 'warn',
});
try {
return await beasties.process(html);
} catch (err) {
// Don't fail the build over critical CSS extraction
console.warn('[critical-css] extraction failed:', err.message);
return html;
}
},
};
}
export default defineConfig({
plugins: [criticalCssPlugin()],
});
The apply: 'build' is important. You don’t want this running in dev mode — it adds latency to HMR and gains you nothing. The try/catch with a fallback is also deliberate. A broken critical CSS extractor should never block your deployment.
Angular Setup
If you’re using Angular CLI 17+, beasties is already wired in. Check your angular.json:
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"optimization": {
"styles": {
"minify": true,
"inlineCritical": true
}
}
}
}
}
}
}
}
inlineCritical: true is what triggers beasties under the hood. If you’re on an older version using critters, the option is the same — Angular just swapped the underlying package.
For SSR (Angular Universal) apps, critical CSS works per-route since each route produces a separate HTML file during prerendering. This is where you get the real benefit: each page gets only the CSS it actually needs.
Gotchas
Gotcha 1: Single-page apps with no prerendering get almost nothing.
If your build produces a single index.html with a <div id="app"></div> and the rest is client-rendered JavaScript, beasties sees nearly nothing in the DOM. It’ll inline almost no CSS because there’s nothing to match against. You need either server-side rendering or static site generation for critical CSS extraction to be meaningful.
Gotcha 2: Media queries are handled conservatively.
By default, beasties inlines CSS rules regardless of their media query context. A rule inside @media (min-width: 1200px) might get inlined even for mobile pages. Use the media option carefully, or accept slightly over-inlined CSS in exchange for simplicity.
Gotcha 3: CSS-in-JS libraries are invisible.
Styled-components, emotion, Stitches — anything that generates styles at runtime in JavaScript doesn’t exist in your static CSS files. beasties has nothing to extract. This isn’t a bug, it’s just out of scope. If you’re on CSS-in-JS, look at each library’s own server-side extraction utilities.
Gotcha 4: Large inline <style> blocks hurt HTTP/2 push and caching.
The whole point of external stylesheets is browser caching. Once you inline CSS, those bytes are repeated on every page load. For very large sites with millions of pages, this tradeoff can go the wrong way. The sweet spot is usually 10–30KB of inlined CSS per page. If beasties is inlining 100KB, something’s wrong with your CSS architecture, not with the tool.
Gotcha 5: The noscript fallback matters more than you think.
beasties transforms your stylesheet link to use onload. If JavaScript is disabled or fails, users get an unstyled page unless the noscript fallback is present. beasties adds this automatically, but if you’re doing post-processing on your HTML elsewhere in the pipeline, verify it’s not getting stripped.
Gotcha 6: CSP headers.
Inline styles and <style> blocks require 'unsafe-inline' in your Content Security Policy, or you need to use nonces. If you’re running a strict CSP (and you should be), configure beasties to emit a nonce:
new Beasties({
nonce: 'your-nonce-value',
})
In practice this means your SSR layer generates a nonce per request, passes it to the build tool configuration (or to the post-render beasties call), and sets the matching CSP header. It’s more work but it’s the right way to do it.
Production-Ready Configuration
Here’s a full webpack configuration I’d actually use in production:
const Beasties = require('beasties-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = (env) => ({
mode: 'production',
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader, // Extract CSS to separate files
'css-loader',
],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'assets/[name].[contenthash:8].css',
}),
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
removeComments: true,
collapseWhitespace: true,
},
}),
// Beasties must come AFTER HtmlWebpackPlugin
new Beasties({
preload: 'swap',
pruneSource: true,
inlineFonts: false,
fontDisplay: 'swap',
minimumExternalSize: 0,
logLevel: env.production ? 'warn' : 'silent',
}),
],
});
The plugin order matters. beasties needs to run after HtmlWebpackPlugin has already written the HTML output. Webpack plugin execution order follows array order, so keep beasties last among HTML-related plugins.
Verifying It Actually Works
After your build, open the generated HTML and look for a <style> block in <head>. You should see minified CSS there. Also look for your stylesheet link — it should look something like:
<link rel="preload" as="style" href="/assets/main.abc123.css"
onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="/assets/main.abc123.css">
</noscript>
If the link is still a plain <link rel="stylesheet">, the plugin didn’t run or failed silently.
For measuring actual impact, run Lighthouse before and after. The "Eliminate render-blocking resources" audit should either disappear or show significantly reduced savings. More importantly, watch your FCP and LCP numbers — a well-implemented critical CSS setup typically knocks 200–600ms off LCP on first load.
You can also use Chrome DevTools’ Coverage tab (Cmd+Shift+P → "Coverage") to see what percentage of your inlined CSS is actually used on the page. Anything above 80% is solid. If you’re below 50%, your above-the-fold component tree is probably not being fully captured in the HTML beasties is seeing.
When Not to Bother
Critical CSS extraction is high-ROI on content sites, marketing pages, and anything where organic search traffic matters. Google uses Core Web Vitals in ranking, and LCP directly correlates with CSS render-blocking behavior.
Skip it or deprioritize it when:
- Your app is behind a login wall. Search engines don’t see it, and returning users have the CSS cached.
- You’re already running HTTP/2 push for stylesheets. The delta shrinks significantly.
- Your CSS bundle is under 10KB total. At that size, the inlining overhead adds more bytes than it saves time.
- Your users are on a corporate LAN with sub-5ms RTT to your server. The latency that critical CSS optimization targets essentially doesn’t exist there.
The Critters-to-Beasties Migration
If you have critters-webpack-plugin in your project:
npm uninstall critters-webpack-plugin
npm install --save-dev beasties-webpack-plugin
In webpack.config.js:
- const Critters = require('critters-webpack-plugin');
+ const Beasties = require('beasties-webpack-plugin');
plugins: [
- new Critters({ ... }),
+ new Beasties({ ... }),
]
All existing options carry over. If you hit any behavior differences, the beasties changelog is worth reading — a few edge cases around selector handling were fixed that critters never addressed.
Closing Thoughts
Critical CSS extraction is one of those build pipeline additions that has an unusually good effort-to-impact ratio. One plugin, half an hour of configuration and testing, and you get a permanent LCP improvement that compounds over every page load.
The tools aren’t perfect. The static DOM analysis approach has real limitations with heavily client-rendered apps, and you’ll occasionally need to debug why a specific selector didn’t get inlined. But for the vast majority of server-rendered or statically generated sites, beasties just works — and the performance numbers justify the occasional edge case debugging session.
Add it to your pipeline, verify the output HTML looks right, run Lighthouse, and watch your scores move.