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.
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 type | Most likely cost | Primary lever |
|---|---|---|
Hero <img> | Download + late discovery | Preload, fetchpriority, AVIF/WebP |
| CSS background image | Very late discovery | Preload (the parser can't see it in markup) |
| Heading / text block | Render delay from CSS + fonts | Inline 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-Controlso the CDN caches the document; pair a shorts-maxagewithstale-while-revalidateso 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" />
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
srcsetpick. - 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) | Bytes | LCP (throttled mobile) |
|---|---|---|
| Original JPEG q90 | 1.4 MB | 2.7s |
| WebP q80 | 240 KB | 1.9s |
| AVIF q80 | 180 KB | 1.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 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.
Related
- Parent: Performance Optimization & Core Web Vitals for SSGs — where LCP fits among the Core Web Vitals.
Go deeper on a single step
- Optimizing LCP on Astro with Priority Hints —
fetchpriority, preload, and Astro'spriorityprop. - Reducing LCP from Hero Images on Static Sites — sizing, format, and the lazy-loading trap.
- Eliminating Render-Blocking CSS on Static Sites — inline critical CSS and defer the rest.
Related performance topics
- Image Optimization Pipelines in Astro — the build-time image work behind the download span.
- Font Loading Strategies for Static Sites — preload, self-hosting, and metric-matched fallbacks for text LCP.
- CDN Caching Rules for SSGs — edge caching and headers that keep TTFB low.
- JavaScript Hydration & Partial Rendering — keeping client JS from delaying the paint.