Pre-rendered HTML gets you 90% of the way to green Core Web Vitals by construction; the last 10% is fonts, the LCP image, and whichever third-party script you added most recently. The bars: LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1 — measured at the 75th percentile of real Chrome users, not in Lighthouse. A static site failing any of them has one of about five specific bugs, and all five are an afternoon's work.
The metrics and the usual static-site suspect
| Metric | Green at p75 | On a static site, it's usually |
|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.5s | Hero image lazy-loaded or not prioritised; web font blocking the headline |
| INP (Interaction to Next Paint) | ≤ 200ms | Third-party scripts hogging the main thread |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | Images without dimensions; font-swap reflow; injected banners |
Definitions and thresholds are documented at web.dev/articles/vitals; everything below is the static-site-specific part.
Fonts are a tax you chose
The system font stack costs 0 bytes, 0 requests, and 0 layout shift. If the design keeps a web font, pay the minimum: self-host a subset WOFF2 (a latin-subset variable font like Inter runs ~45KB, and self-hosting avoids the extra connection to Google Fonts — typically 100–300ms on a cold load), preload it, and give the fallback matched metrics so the swap doesn't move anything:
<link rel="preload" href="/fonts/inter-var.woff2"
as="font" type="font/woff2" crossorigin>
<style>
@font-face {
font-family: Inter;
src: url(/fonts/inter-var.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
}
</style>
The size-adjust/ascent-override pair is the underused piece: it makes the fallback occupy the same space as the web font, which converts the classic swap-jolt into a CLS contribution of approximately zero.
The LCP element is usually one image (or one headline)
Three rules for the hero image: intrinsic width and height attributes always (this alone fixes most image CLS), fetchpriority="high" on the one image that is the LCP element, and never loading="lazy" above the fold — lazy-loading the LCP image routinely adds hundreds of milliseconds because the browser deliberately deprioritises it. Serve AVIF or WebP; AVIF typically lands 30–50% smaller than JPEG at equivalent quality.
On text-heavy pages — most of a directory — the LCP element is the H1, and LCP reduces to TTFB plus font loading. Static files behind a CDN give you TTFB under 100ms without trying, which is why a static site failing LCP is nearly always failing on the two items above rather than on hosting.
INP dies by JavaScript you didn't write
A pre-rendered page has almost no interaction cost of its own, so INP failures come from third parties: tag managers, chat widgets, consent platforms, and heavyweight analytics. The gap is not subtle — GA4's gtag.js weighs in around 90KB compressed and executes on the main thread, while Plausible is under 1KB and Umami about 2KB. Swapping analytics is usually the whole INP fix; the self-hosted options are compared in measuring SEO with self-hosted analytics.
Beyond that: defer every script without exception, and skip tag managers entirely on a static site — GTM exists to let non-engineers inject scripts, which is a capability a static site should not have.
CLS: reserve space for everything that arrives late
The rule generalises from images: anything that appears after first paint must have its space reserved before first paint. Dimensions on images and iframes, aspect-ratio boxes for embeds, fixed-height slots for ad units if you run them — a min-height matching the unit's most common size costs nothing when the ad fills it and saves a 0.2+ CLS hit when the ad arrives late. The classic late offender is the cookie-consent banner that pushes the whole page down — one more argument for cookieless analytics, since no cookies means no banner and one fewer CLS source.
Optimise field data, not Lighthouse
Rankings use CrUX field data at p75; Lighthouse is a lab simulation on a throttled connection. The scoreboard that matters is the Core Web Vitals report in Search Console, with PageSpeed Insights' field panel for per-URL detail. Two consequences worth internalising: a Lighthouse score of 100 with failing field data means your real users (often on mid-range Android over cellular) are not seeing what the lab sees; and if your site lacks CrUX data because traffic is low, CWV is effectively not among your ranking problems — spend the afternoon on content or internal linking instead.
Budget for the reporting lag, too: CrUX aggregates over a rolling 28-day window, so a fix shipped today reaches a clean p75 about a month later. Ship the change, verify it in lab tooling the same day, then leave the field report alone for four weeks before judging — re-litigating a CWV fix at day ten is how people talk themselves into reverting things that were working.
What I'd do
System fonts, or one preloaded, subset, metric-matched variable font. A build step that injects image dimensions so a missing width can never ship. fetchpriority="high" on the hero, lazy-loading on everything below the fold. Analytics under 2KB and every script deferred. Then check the Search Console CWV report once a month and treat any URL group going amber as a regression with a name — on a static site, a Vitals failure is always one specific mistake, never a mystery.