Critical CSS, Blitz and Imager X: how we make Craft 5 sites fast

—

Our older posts on page speed were written when the big wins were minifying files and sprinkling caching plugins. Craft 5, Tailwind 4 and Core Web Vitals changed what matters, so here is what we actually do on this site today, in three parts: critical CSS, static caching and images.

Critical CSS, per page

Tailwind gives us one stylesheet. Loading it blocks rendering, so we inline the CSS needed for the first screen and load the rest without blocking.

Generating it

Two npm scripts do the work (npm run critical:all runs both):

  1. critical:update (scripts/fetch-critical-urls.mjs) calls a private Craft endpoint, set in critical-config.json, which returns the URLs to process as JSON, and saves them to critical-urls.json. Because the list comes from Craft, new sections get covered without editing the script.
  2. critical:generate (scripts/generate-critical.mjs) visits each URL with Puppeteer through the critical package, which uses Penthouse to extract above-the-fold CSS. We run it at two viewports, 1300×900 and 390×844, so desktop and mobile are both covered, then minify the result with cssnano.
const { css } = await generate({
    src: url,
    inline: false,
    dimensions: [
        { width: 1300, height: 900 },
        { width: 390,  height: 844 },
    ],
    penthouse: {
        forceInclude: globalForceInclude,
        renderWaitTime: 2000,
    },
});

Three details were learned the hard way:

  • A fresh browser per route, so no state leaks between pages.
  • A forceInclude list. Our header changes with scroll position (a .is-top class on body), and Penthouse can't see states that only exist after scrolling. We list those selectors so they are always kept. Without this you get an unstyled flash when the header changes.
  • A patch for Tailwind 4. Tailwind 4 wraps its CSS in @layer blocks, and Penthouse 2.3.3 left empty @layer rules in the output. We patch its final-rule-remover.js to treat layer like media and supports, applied automatically with patch-package on npm install (patches/penthouse+2.3.3.patch).

We also keep a hand-written templates/_critical/base.css that is appended to every generated file for overrides.

Serving it

Output goes to templates/_critical/, mirroring each section's template path. In _base.twig, the template for the current entry's section decides which file to inline:

{% set criticalPath = '_critical/' ~ entry.section.siteSettings[craft.app.sites.currentSite.id].template|replace({'.twig': ''}) ~ '.css' %}
{% set critical = source(criticalPath, ignore_missing: true) %}

{% if critical|trim is not empty %}
    <style id="critical">{{ critical|raw }}</style>
    {{ craft.vite.script('src/js/app.js', true, {defer: true}) }}
{% else %}
    {{ craft.vite.script('src/js/app.js', false) }}
{% endif %}

ignore_missing: true means a page with no critical file falls back to the normal blocking stylesheet instead of erroring, so it is slower but never broken. Sections with several entry types use a criticalPathExtension to pick a file per type. Setting DEBUG_CRITICAL_CSS makes the page log to the browser console whether its file was found.

npm run prod-and-critical does the full build followed by generation, and the files are regenerated whenever the design changes.

Blitz static caching

Blitz writes rendered pages to static files, so most requests skip PHP and the database. In this project it is enabled in production only:

'cachingEnabled' => (getenv('ENVIRONMENT') == 'production' ?? false)

so development always renders live.

We don't use a deployment pipeline. Changes go live by uploading the files, so clearing the cache is a deliberate step rather than something a deploy does for us. There are two ways we do it:

  • Manually in the control panel. After uploading template or asset changes, we clear the Blitz cache from the CMS (or run php craft blitz/cache/flush-all) so visitors don't get pages built from the old templates. Editing content doesn't need this, because Blitz refreshes the affected pages itself when an entry is saved.
  • From Composer. Where a site is updated with composer install, we hook the cache clear into the post-install-cmd event in composer.json, next to ./craft up --interactive=0, so it runs every time dependencies are installed.
"post-install-cmd": [
  "./craft up --interactive=0",
  "./craft blitz/cache/flush-all"
]

Either way, regenerate the critical CSS files before you clear the cache, otherwise the freshly cleared pages are rebuilt with the old inlined styles.

Images with Imager X

Images are usually the heaviest thing on a page. We use Imager X with named presets in config/imager-x-transforms.php, so editors never choose sizes:

'default' => [
    'transforms' => [
        ['width' => 480], ['width' => 768], ['width' => 1024],
        ['width' => 1280], ['width' => 1440],
    ],
    'defaults' => ['ratio' => 4/3],
],
'hero' => ['transforms' => 'default', 'defaults' => ['ratio' => 2]],

One shared template, _includes/elements/image.twig, turns a preset into a responsive image:

  • a srcset and a context-specific sizes value, so phones download the 480px version;
  • width and height set from the first transform, which prevents layout shift;
  • loading="lazy" by default, and fetchpriority="high" for the hero image;
  • the focal point from Craft's asset editor, passed to Imager so crops keep the subject;
  • SVGs bypass transforms entirely.

Transforms are stored on S3 under a transforms folder behind CloudFront, with jpegoptim and optipng run on output, and CloudFront invalidation is enabled. WebP is served in production.

Results

We measure with Lighthouse CI (npm run perf): mobile emulation with simulated throttling, three runs per page, median reported. The budgets live in lighthouserc.js, so a regression fails the audit rather than relying on someone noticing. These figures are from the live site on 16th October 2026.

  • Homepage: Performance 69, FCP 1.4s, LCP 7.9s, CLS 0.10, TBT 46ms
  • Blog post: Performance 80, FCP 1.0s, LCP 4.3s, CLS 0.14, TBT 42ms
  • Services: Performance 76, FCP 1.0s, LCP 5.1s, CLS 0.14, TBT 40ms
  • Case Study: Performance 78, FCP 1.0s, LCP 5.1s, CLS 0.09, TBT 35ms

Some of this is what we wanted to see, and some isn't:

  • The critical CSS is doing its job. Lighthouse reports no render-blocking resources on any of the four pages, first paint lands at about a second on three of them, and total blocking time is under 50 ms everywhere.
  • Largest Contentful Paint misses budget on every page. Server response is not the problem: with Blitz serving cached pages, Lighthouse measures the server responding in about 25 ms, against 1.8 to 2 seconds on our development site, where caching is off. The time goes on getting the main image onto the screen. On these pages the image takes between two and four and a half seconds to download under mobile throttling.
  • Pages are heavier on live than on dev. Total transfer is roughly 1.2 to 2.3 MB on live against 0.4 to 1.4 MB on dev, and the largest requests on every page are the Google Tag Manager and Google Ads scripts, which dev doesn't load. Third-party tags compete with the hero image for bandwidth, and it is the first thing we will look at.
  • The LCP images are already set up correctly. They are not lazy-loaded, they carry fetchpriority="high" and explicit dimensions, and they are served as responsive WebP. The remaining gap is image weight and competing requests, not missing hints.
  • CLS is borderline. The blog post and services page sit at 0.14 against a 0.1 budget.

So the honest summary is that per-page critical CSS and Blitz have removed the server and stylesheet delays, and what is left is image weight and third-party scripts. This is a useful test though as it shows us exactly where we can make improvements and retesting is very easy.

What we'd do differently

  • Critical CSS goes stale. The generated files are a snapshot of the design on the day we ran the script. If we change a layout and forget to regenerate, pages quietly inline the old styles. We now treat npm run prod-and-critical as part of any design change, not an optional extra.
  • The forceInclude list is manual upkeep. Penthouse can't see states that only appear after scrolling, so every new header state or scroll-triggered class has to be added to the list by hand. If it isn't, the first sign is an unstyled flash on live pages, not a build error.
  • Patching a dependency has a cost. The Tailwind 4 fix to Penthouse works, but it is a patch we have to keep an eye on whenever the package updates, and we would rather it was fixed upstream.
  • Cache clearing is a step to remember. Without a deployment pipeline, a Blitz clear after uploading files is something a person has to do (or a Composer hook has to do for them). Forgetting it means visitors see pages built from old templates.

Summary

  • Layer: First paint, Tool: Per-page critical CSS, What it saves: Render-blocking stylesheet
  • Layer: Server time, Tool: Blitz, What it saves: PHP and database work
  • Layer: Page weight, Tool: Imager X presets, What it saves: Oversized images

If you're comparing platforms, the client-friendly version of this is in Craft CMS vs WordPress: the real costs.