Largest Contentful Paint Optimization for Static Sites

Largest Contentful Paint (LCP) measures how long it takes for the largest visible element in the viewport — usually a hero image, a heading, or a background image — to render. Static site generators hand you a head start because the HTML arrives pre-rendered in a single response, but the LCP element still has to be discovered, downloaded, and painted. This guide is the complete LCP workflow for static sites: find the LCP element, get it requested as early as possible, serve it in a fast format, and stop CSS and fonts from blocking the paint. It sits within the broader Performance Optimization & Core Web Vitals for SSGs effort, where LCP is the metric most directly tied to perceived load speed.

Every step below ties to a measured number and the tool that produced it (Lighthouse, lhci, WebPageTest). The running example is a documentation landing page built with Astro, deployed to a CDN, and measured on a throttled mid-tier mobile profile (Moto G-class, 4x CPU slowdown, ~1.6 Mbps). The baseline LCP was 3.4s; the finished page lands at 1.6s.

Where the LCP element's time goes A waterfall showing the four spans that make up Largest Contentful Paint on a static site: time to first byte, resource discovery delay, image download, and render delay, with the levers that shrink each span. LCP = TTFB + discovery + download + render delay TTFB edge cache Discovery preload & priority Download AVIF/WebP size Render delay critical CSS & fonts Same spans as a waterfall — shrink the widest first 0.2s discovery 0.9s download 1.4s render 0.9s Baseline waterfall — total LCP 3.4s. The download and discovery spans are the widest, so they get fixed first. After preload + AVIF + critical CSS, every span shrinks and total LCP falls to 1.6s.
LCP is the sum of four spans. Measure the waterfall, find the widest span, and apply the matching lever — discovery, download size, or render delay.

Step 1: Identify the LCP Element

You cannot optimize what you have not measured. Before touching markup, find the exact element the browser counts as the largest contentful paint, because the fix is different for an image, a heading, or a CSS background.

Lighthouse reports it directly. Run it against a deployed URL and open the Largest Contentful Paint element audit, which names the DOM node and the four sub-parts of LCP (TTFB, load delay, load time, render delay). For a programmatic check, drop a PerformanceObserver into the console:

new PerformanceObserver((list) => {
  const entry = list.getEntries().at(-1);
  console.log('LCP element:', entry.element, 'at', Math.round(entry.startTime), 'ms');
}).observe({ type: 'largest-contentful-paint', buffered: true });

On our example page the LCP element was the hero <img>, paint timestamp 3,400 ms. WebPageTest confirmed it by marking the hero frame in the filmstrip. Knowing the element is an image — not text — told us the dominant cost was the download span, so that is where we started.

LCP element typeMost likely costPrimary lever
Hero <img>Download + late discoveryPreload, fetchpriority, AVIF/WebP
CSS background imageVery late discoveryPreload (the parser can't see it in markup)
Heading / text blockRender delay from CSS + fontsInline critical CSS, preload font

Step 2: Confirm TTFB Is Not the Bottleneck

Discovery, download, and render all begin only after the first byte of HTML arrives, so a slow Time to First Byte (TTFB) shifts every later span to the right. Check it before touching markup. Pre-rendered HTML makes this the easiest span to win: the page is a static file, so it can be served straight from a CDN edge cache with no origin round trip. On our example page the document was already an edge cache HIT at 0.2s, so TTFB was not the problem — but when the waterfall shows a fat first span, the fixes are cache-side, not markup-side:

  • Serve HTML from the edge, not the origin. A cache MISS that falls back to a regional origin can add 200-600 ms before a single byte of markup ships.
  • Set an explicit Cache-Control so the CDN caches the document; pair a short s-maxage with stale-while-revalidate so a redeploy never forces a cold fetch onto the critical path.
  • Preconnect to any third-party origin the LCP resource depends on (an image CDN, a font host) so the TLS handshake overlaps with HTML parsing:
<link rel="preconnect" href="https://images.example-cdn.com" crossorigin />

The header and cache-key details — how to keep a static HTML document cacheable while still invalidating it cleanly on deploy — are covered in CDN Caching Rules for SSGs.

Step 3: Make the LCP Resource Discoverable Early

The single biggest waste in LCP is a late request. The browser's preload scanner finds resources by reading HTML, so an <img> near the top is found quickly — but a CSS background-image, an image injected by a script, or an image far down the DOM is discovered late, after the stylesheet that referenced it has already downloaded.

Two tools fix discovery. First, fetchpriority="high" tells the browser this image matters more than the other images it found, so it gets a connection ahead of them:

<img src="/hero.avif" alt="Product dashboard" width="1200" height="600"
     fetchpriority="high" />

Second, for resources the parser cannot see early — background images, or an image the framework outputs late — add an explicit preload in <head>:

<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
Late discovery versus an early preload Two request timelines measured from navigation start. Without a priority hint the browser only discovers the hero image after the stylesheet finishes, so the hero request starts at 910 milliseconds. With fetchpriority and a preload link the hero request starts at 120 milliseconds, in parallel with the CSS. A preload starts the hero request 790 ms sooner Before no hint After preload HTML CSS downloads — hero not yet discovered HTML hero AVIF preload runs in parallel with CSS 910 ms 120 ms 790 ms sooner 0 250 500 750 1000 ms
Without a hint the browser finds the hero only after the stylesheet loads. fetchpriority plus a preload starts the request in parallel — moving it from 910 ms to 120 ms after navigation start.

In our measurement, adding fetchpriority="high" plus a preload moved the hero request from 910 ms to 120 ms after navigation start and cut LCP from 3.4s to 2.7s — a 0.7s win with two lines of HTML. The Astro-specific mechanics (the priority prop, eager loading, and where preload belongs) are covered in Optimizing LCP on Astro with Priority Hints.

Step 4: Shrink the LCP Image Download

Discovery only helps if the file is small enough to arrive quickly. The download span is usually the widest part of LCP on an image-led page, and the fix is the same build-time work that drives the whole Image Optimization Pipelines in Astro approach: serve a modern format at the right size.

  • Format: AVIF is typically 20-30% smaller than WebP at matched quality, and WebP is 25-50% smaller than JPEG. Emit both with a fallback.
  • Dimensions: never ship a 2400px source into a 1200px slot. Generate the widths the layout actually uses and let srcset pick.
  • Never lazy-load the hero: loading="lazy" on the LCP image defers it behind layout, which is the opposite of what you want.
<picture>
  <source type="image/avif" srcset="/hero-800.avif 800w, /hero-1200.avif 1200w"
          sizes="(max-width: 800px) 100vw, 1200px" />
  <source type="image/webp" srcset="/hero-800.webp 800w, /hero-1200.webp 1200w"
          sizes="(max-width: 800px) 100vw, 1200px" />
  <img src="/hero-1200.jpg" alt="Product dashboard" width="1200" height="600"
       fetchpriority="high" decoding="async" />
</picture>
Hero source (1200px)BytesLCP (throttled mobile)
Original JPEG q901.4 MB2.7s
WebP q80240 KB1.9s
AVIF q80180 KB1.7s

Swapping the 1.4 MB JPEG for a 180 KB AVIF cut the download span from 1.4s to 0.3s and brought LCP to 1.7s. The full hero recipe — sizing, srcset, and the lazy-loading trap — is in Reducing LCP from Hero Images on Static Sites.

Step 5: Remove Render-Blocking CSS

Even a tiny LCP element cannot paint until the CSS that styles the page has loaded and parsed. A single external stylesheet in <head> blocks the first paint for an entire network round trip. The fix is to inline the small slice of CSS needed to render the above-the-fold view and load the rest non-blocking:

<head>
  <style>/* critical CSS: layout, hero, typography for the first viewport */</style>
  <link rel="preload" href="/styles.css" as="style"
        onload="this.onload=null;this.rel='stylesheet'" />
  <noscript><link rel="stylesheet" href="/styles.css" /></noscript>
</head>

On our page the external stylesheet was 78 KB and blocked first paint for ~0.6s. Inlining a 9 KB critical slice and deferring the rest moved First Contentful Paint earlier and shaved the LCP render delay, contributing the final step from 1.7s to 1.6s while improving FCP more dramatically (2.1s to 0.9s). The full critical-CSS and purge workflow — including how to generate the critical slice and prune unused rules — is in Eliminating Render-Blocking CSS on Static Sites.

Step 6: Keep Fonts From Delaying the Paint

When the LCP element is a text block, web fonts become the bottleneck. With font-display: swap the browser paints fallback text first and repaints when the web font arrives; if the LCP text uses the web font, its measured paint can move to that later repaint. Two disciplines keep text LCP early:

<link rel="preload" as="font" type="font/woff2"
      href="/fonts/inter-var.woff2" crossorigin />
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
  size-adjust: 102%; /* metric-matched fallback to avoid reflow */
}

Preload the one weight that appears above the fold, self-host it to avoid a third-party connection, and use a metric-matched fallback so the swap does not shift layout. The full font workflow lives in Font Loading Strategies for Static Sites. On text-led pages, preloading the hero weight typically moves LCP earlier by 200-400 ms because the heading paints in its final font on the first paint.

Common Pitfalls

  • Preloading the wrong resource: a preload for an image the page does not actually use as its LCP wastes bandwidth and can delay the real LCP resource. Always re-run Lighthouse after adding a preload.
  • fetchpriority="high" on many images: if everything is high priority, nothing is. Reserve it for the single LCP element.
  • Lazy-loading the hero: loading="lazy" on the above-the-fold image is the most common self-inflicted LCP regression on static sites.
  • Critical CSS drift: generated critical CSS goes stale when the design changes. Regenerate it in the build, not by hand, or it will block paint with rules the page no longer needs.
  • Hydration blocking the paint: a heavy client bundle that hydrates the hero region can push render delay out even after CSS and fonts are handled. Ship less JavaScript above the fold — the trade-offs are in JavaScript Hydration & Partial Rendering.
  • Counting lab numbers as field numbers: a great Lighthouse LCP can still be poor in the field if real users are on slower networks. Confirm with real-user monitoring at the 75th percentile.
The four parts of LCP, in the order they happen Four sequential components of the LCP measurement: time to first byte, resource load delay, resource load duration and element render delay. Each band names what controls it on a static site. The four parts of LCP, in the order they happen Time to first byte edge cache hit or origin round trip Resource load delay how early the browser discovers the LCP image Resource load duration the bytes of that image, and its format Element render delay render-blocking CSS and fonts On a cached static site the first band is usually already small, which surprises teams who start there.
Measure which band dominates before optimising: the fix for each is completely different and they rarely dominate together.

The banding also explains why some optimisations disappoint. Shrinking a hero image helps only if load duration is the dominant band; if the browser did not discover the image until late, the fix is a preload or a fetchpriority hint and the file size is beside the point. Chrome's Performance panel breaks the LCP measurement into exactly these four parts, which makes the diagnosis a thirty-second exercise rather than a guess.

The Order That Actually Works

The six steps above are ordered deliberately, and following them in a different order is the most common reason an LCP effort stalls.

Identify before optimising. The LCP element is frequently not what people assume — on documentation pages it is often a heading or the first paragraph rather than an image, and the entire image-optimisation plan then applies to something that was never the bottleneck. Chrome reports the element directly; take ten seconds and look.

Fix discovery before size. If the browser learns about the LCP resource late — because it is referenced from CSS, injected by script, or simply far down the document — then shrinking it changes almost nothing. A preload or a priority hint moves the request earlier, and only then does the file size become the limiting factor.

Fix render-blocking before fonts. A blocking stylesheet delays every paint, including the one that would have shown the text; a font delays only text. Removing the stylesheet block often makes the font problem visible for the first time, which is why doing it second wastes a measurement.

Re-measure between every step. Each fix changes which band dominates, and the next step is decided by the new measurement rather than the plan. Teams that batch all six changes into one pull request cannot tell which of them helped, which matters the next time the number regresses.

The whole sequence is usually a day of work on a template, and it applies to every page using that template — which is the leverage a static site offers over per-page tuning.

Finally, remember that the metric is assessed at the 75th percentile of real page views, not on your laptop. A template that measures 1.6 seconds locally and 3.4 seconds in the field is not a measurement error — it is the device and network mix of your actual readers, and the field number is the one that counts.

Track the number per template after every deploy for a week, not once. LCP work has a habit of regressing quietly — a new hero component, a font added for one page, a third-party embed above the fold — and the template-level series is what makes that visible before a reader notices.

Key Takeaways

  • LCP is four spans — TTFB, discovery, download, render delay. Measure the waterfall and fix the widest first.
  • Identify the LCP element before optimizing; the fix differs for an image, a background, or text.
  • Confirm TTFB is low first — a pre-rendered page served from an edge cache HIT gives every later span a head start.
  • Make the LCP resource discoverable early with fetchpriority="high" and a targeted preload.
  • Shrink the image with AVIF/WebP at the right width, and never lazy-load the hero.
  • Inline critical CSS and preload the above-the-fold font so render delay does not gate the paint.

FAQ

What is a good LCP target for a static site?

Aim for 2.5 seconds or less at the 75th percentile of real users, which is the "good" threshold Google uses. On a pre-rendered static page with an optimized hero you can usually reach 1.5 to 2.0 seconds in the lab on a throttled mobile profile, which leaves headroom for slow real-world networks.

How do I find which element is the LCP element?

Run Lighthouse and open the "Largest Contentful Paint element" audit, or use the PerformanceObserver API for largest-contentful-paint entries in the browser console. WebPageTest also marks the LCP frame in its filmstrip. The element is most often a hero image, a heading, or a background image.

Does preloading the LCP image always help?

It helps when the image is discovered late — for example a CSS background image or an image deep in the DOM. If the image is already an early <img> tag with fetchpriority="high", an extra preload adds little and can waste bandwidth on the wrong resource, so always measure before and after.

Why does CSS affect LCP if my content is just text?

Render-blocking CSS delays first paint entirely. The browser will not paint the LCP text block until the stylesheet that governs it has loaded and parsed. Inlining the critical CSS lets the first paint happen on the initial HTML response instead of waiting for a separate round trip.

Can web fonts make LCP worse?

Yes, when the LCP element is a text block. With font-display: swap the browser may delay painting that text or repaint it when the web font arrives, and the LCP timestamp moves to the later paint. Preloading the one above-the-fold weight and using a metric-matched fallback keeps the text paint early and stable.

Is LCP a problem on static sites at all, given they pre-render HTML?

Pre-rendering gives you a strong starting point because the markup arrives in one response, but the LCP resource itself — a hero image, a font, or a stylesheet — still has to download and render. Those are exactly the things this guide controls, which is why measured static sites still move from roughly 3 seconds to under 2 seconds after this work.

Go deeper on a single step

Related performance topics