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

MetricGreen at p75On a static site, it's usually
LCP (Largest Contentful Paint)≤ 2.5sHero image lazy-loaded or not prioritised; web font blocking the headline
INP (Interaction to Next Paint)≤ 200msThird-party scripts hogging the main thread
CLS (Cumulative Layout Shift)≤ 0.1Images 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.