A perfect Lighthouse on Astro and Cloudflare
The playbook behind kairn.in: static-first Astro, inline critical CSS, subsetted fonts, sized images, viewport prefetch, no default client JS, and sane cache headers.

Our marketing site, kairn.in, is built with Astro 5 as a static site and served from Cloudflare. We aim for a perfect Lighthouse score on every page, and we treat that as a target to keep hitting, not a trophy from one good run. Scores shift with the test device, the network profile and the Lighthouse version, so we will not quote numbers here. What we can share is the playbook that makes the target realistic.
The short version: ship less, ship it earlier, and tell the browser everything you know in advance.
Speed is mostly the discipline of not sending things.
Static first
Every page on kairn.in is HTML generated at build time. No server renders anything per request, and there is no hydration step waiting to catch up. A static file on a CDN edge is the fastest response a web server can give, and it is also the hardest to break.
Astro suits this because its default output is zero client-side JavaScript. Components render to HTML at build time and their script is dropped. When a page needs interactivity, such as a menu toggle or a form, we write a few lines of vanilla JS in a small inline or deferred script rather than hydrating a framework island.
Our rule of thumb: if a component can be HTML and CSS, it is. If it needs behavior, we reach for the smallest tool that does the job, usually the platform itself. Details and dialog elements, CSS :has() and scroll-snap replace a surprising number of widgets.
Here is the shape of our Astro config:
// astro.config.mjs
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
export default defineConfig({
site: "https://kairn.in",
output: "static",
trailingSlash: "never",
compressHTML: true,
build: {
// Inline stylesheets small enough to beat a network round trip.
inlineStylesheets: "auto",
},
prefetch: {
prefetchAll: false,
defaultStrategy: "viewport",
},
integrations: [sitemap()],
});
Nothing exotic is in there, which is the point. output: "static" removes a whole class of problems, compressHTML trims whitespace, and the prefetch block deserves its own section below.
Critical CSS, inlined
Render-blocking CSS delays first paint. A page cannot draw anything until the stylesheets in its head have arrived, so every external stylesheet costs a round trip before the user sees pixels.
Our approach is plain. The site’s whole stylesheet is small, because we write a compact design system with custom properties and avoid utility bloat. Astro’s inlineStylesheets: "auto" inlines stylesheets under a size threshold directly into the HTML. For the first screen, that means the markup and the CSS arrive in one response.
If a stylesheet grows past the threshold, we split it: a critical layer for the layout, type and above-the-fold components goes inline, and below-the-fold styles load afterward. The question for every rule is whether the first screen needs it.
A few habits keep CSS small:
- Scope component styles so unused rules get dropped with the component.
- Prefer custom properties for theming over duplicated rule sets.
- Avoid importing a whole icon or component library for a handful of shapes. Inline the SVGs you use.
Fonts without the flash
Fonts are where many fast sites lose their score. A web font blocks text rendering or causes a layout shift when it swaps in. We handle both.
Self-host. No third-party font CDN means no extra DNS lookup or connection. The files sit on the same origin and the same cache policy.
Use WOFF2 and subset. We only ship the glyphs we need. A Latin subset of a variable font is a small fraction of the full family. If a page needs Devanagari or Gujarati for a client project, that goes in a separate subset loaded only on pages that use it, with unicode-range so browsers fetch it only when the characters appear.
Use variable fonts. One variable file covers a range of weights, replacing four or five static files. Subsetting a variable font keeps only the axes in use.
Preload the one that matters. The primary text font gets a preload hint with crossorigin, because the browser would otherwise discover it only after parsing the CSS.
Control the swap. font-display: swap shows text immediately in a fallback. To reduce the jump when the real font arrives, we tune the fallback with size-adjust, ascent-override and related descriptors so metrics line up.
Images that don’t move
Images cause two kinds of damage: weight and layout shift. We address both in markup.
Always set explicit width and height attributes. The browser uses them to reserve space before the file loads, which prevents shifts. With CSS height: auto and max-width: 100%, the image still scales responsively.
Use modern formats. AVIF compresses best and WebP is a widely supported fallback. Astro’s <Image> and <Picture> components generate both at build time, along with a srcset so phones do not download desktop-sized files.
Then set loading behavior deliberately:
- The main image above the fold gets
loading="eager"andfetchpriority="high". - Everything below gets
loading="lazy"anddecoding="async". - Decorative art is CSS or inline SVG where possible, which costs no request at all.
On a page with one hero image, getting that single image right often decides the Largest Contentful Paint.
Prefetch on viewport
Static pages are fast to load but still need a network trip when you click. Prefetching removes that wait. Astro’s built-in prefetch supports several strategies, and we use viewport: when a link scrolls into view, the browser fetches its target quietly in idle time.
By the time someone clicks, the next page is usually in cache and navigation feels instant. Because pages are small static files, speculative fetches are cheap.
We keep this in check. prefetchAll is off, and heavy or rarely visited links can opt out with data-astro-prefetch="false". Prefetching should speed up likely paths without spending a visitor’s data on pages they will never open.
Cloudflare caching headers
The last piece is how the edge and the browser cache what we serve. On Cloudflare Pages, a _headers file in the output directory sets response headers per path pattern.
# public/_headers
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
# Hashed build assets never change. Cache for a year.
/_astro/*
Cache-Control: public, max-age=31536000, immutable
# Fonts are static and same-origin.
/fonts/*
Cache-Control: public, max-age=31536000, immutable
# HTML: let the edge cache it, but make browsers revalidate.
/*.html
Cache-Control: public, max-age=0, must-revalidate
The reasoning is simple. Astro fingerprints files in /_astro/, so a changed file gets a new name and the old name can be cached forever. HTML is the one thing that changes under a stable URL, so browsers revalidate it, and Cloudflare’s edge still serves it close to the visitor. Pages also negotiates Brotli compression for text assets automatically.
A deploy therefore replaces HTML and adds new hashed assets, and visitors pick up changes immediately without any cache purging by hand.
Keeping the score
Getting to a good score is one project. Staying there is another, and it slips when nobody watches. We run Lighthouse in CI against the built site on key templates and fail the build if performance, accessibility, best practices or SEO falls below our target. Because results vary a little between runs, we sample several times and look at the median.
A budget also helps: a ceiling on total JavaScript, total CSS and per-image weight. Anyone adding a script or a big image has to justify it against the budget in the pull request.
Most of this is boring on purpose. Static HTML, small CSS, careful fonts, sized images and long cache lifetimes are all well-understood techniques. The hard part is saying no, page after page, to the thing that would make it slower.
