LCP Optimization: Image Preloading, Critical CSS and SSR
The Largest Contentful Paint (LCP) measures the time it takes for the main content of the page is visible to the user. For Google, the "Good" threshold is 2.5 seconds: beyond this threshold, the organic ranking is penalized. According to CrUX data from March 2026, the 35% of websites still have LCP in the "Needs Improvement" or "Poor" range, often for reasons that can be avoided with targeted interventions.
The LCP element is typically the hero image, main banner, or multiple text block
large above the fold. The optimization strategy depends on the type of item: an image
requires different approaches to a text. In both cases, there are four ad techniques
high impact covering 90% of real cases: LCP image preloading, inlining the
Critical CSS, font preloading with font-display: swap, and Server-Side Rendering.
What You Will Learn
- How to identify LCP element with Chrome DevTools and Lighthouse
- Image preloading with <link rel="preload"> and the fetchpriority="high" attribute
- Critical CSS: what and how to extract it and how to include it inline in HTML
- Font preloading with font-display: swap and preconnect
- How SSR (Server-Side Rendering) structurally improves the LCP
- How to measure LCP improvement before and after optimizations
Identify the LCP Element
The first step is always knowing which element is your LCP. You can identify it in two ways:
- Chrome DevTools: in the Performance panel, after a load recording page, look for the "LCP" marker in the timeline. Click on it to see which item triggered the LCP and how many dependent resources it requested.
-
web-vitals.js: the property
entriesof the LCP metric object contains theLargestContentfulPaintentry with the corresponding DOM element.
import { onLCP } from 'web-vitals';
onLCP((metric) => {
const lcpEntry = metric.entries[metric.entries.length - 1];
console.log('Elemento LCP:', lcpEntry.element);
console.log('Valore LCP:', metric.value, 'ms');
console.log('Rating:', metric.rating); // 'good' | 'needs-improvement' | 'poor'
});
Strategy 1: Image Preloading with fetchpriority
If your LCP element is an image, the most impactful improvement is the preloading
explicit with high priority. By default, the browser only discovers images when
the HTML parser encounters the tag <img>, which can happen long after the
document has started loading. With <link rel="preload">, the browser
start downloading the image as soon as possible.
<!-- Nel <head> dell'HTML, prima di altri tag -->
<link
rel="preload"
as="image"
href="/images/hero.webp"
fetchpriority="high"
imagesrcset="/images/hero-400.webp 400w,
/images/hero-800.webp 800w,
/images/hero-1200.webp 1200w"
imagesizes="100vw"
/>
The attribute fetchpriority="high" (Baseline 2023, 92% support) communicates to
browser that this resource is critical and must be downloaded before prioritized resources
normal. On slow networks, this can make a difference of 0.5-1.5 seconds on the LCP.
Responsive Images: imagesrcset and imagesizes
If you use responsive images with srcset, you have to add imagesrcset
e imagesizes to the preload link to have the browser preload
the correct variant for the current viewport, not the larger version.
WebP and AVIF format
Image format optimization is complementary to preloading. WebP typically reduces the weight of images by 25-35% compared to JPEG. AVIF reduces by 50-55% but has times of higher decoding on older hardware. For LCP image, WebP and optimal format in 2026, with AVIF for progressive improvement on modern browsers.
<picture>
<source
srcset="/images/hero.avif"
type="image/avif"
/>
<source
srcset="/images/hero-400.webp 400w, /images/hero-800.webp 800w"
type="image/webp"
sizes="100vw"
/>
<img
src="/images/hero.jpg"
alt="Hero image"
width="1200"
height="600"
fetchpriority="high"
loading="eager"
/>
</picture>
Do not use loading="lazy" on the LCP element
loading="lazy" delays downloading the image until it is close to the viewport.
For the LCP element, which is by definition already in the viewport, this causes an unnecessary delay.
USA loading="eager" (or no loading attribute) for the LCP image.
Apply loading="lazy" only to images below the fold.
Strategy 2: Critical CSS Inline
By default, the browser does not begin rendering the page until it has downloaded and viewed it
all the CSS linked in the<head>. If your CSS file is large (100KB+) e
served on a slow CDN, this can add 0.5-2 seconds to the LCP. The solution is the
Critical CSS: Extract only the CSS needed to render the content
above the fold and include them inline in the HTML.
<!-- INVECE DI: -->
<link rel="stylesheet" href="/styles/main.css">
<!-- USA: -->
<style>
/* Critical CSS inline - solo quello necessario above the fold */
body { margin: 0; font-family: sans-serif; }
.hero { width: 100%; height: 60vh; background: #f5f5f5; }
.hero h1 { font-size: 2.5rem; color: #333; }
nav { display: flex; justify-content: space-between; padding: 1rem; }
</style>
<!-- Carica il CSS completo in modo non-bloccante -->
<link
rel="preload"
as="style"
href="/styles/main.css"
onload="this.rel='stylesheet'"
/>
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
Tools for Extracting Critical CSS
Manually extracting Critical CSS is not practical on large sites. Use automatic tools:
- Critters (webpack/Vite plugin): Extracts and inlines critical CSS automatically
- critical (npm): CLI tool to extract critical CSS from any URL
- PurgeCSS + Critters: ideal combination for reducing total CSS before inlining
// vite.config.ts - con Critters per Critical CSS automatico
import { defineConfig } from 'vite';
import critters from 'vite-plugin-critters';
export default defineConfig({
plugins: [
critters({
// Inline il critical CSS, carica il resto in modo asincrono
strategy: 'critical',
// Non rimuovere i CSS non usati (lascialo a PurgeCSS)
pruneSource: false,
}),
],
});
Strategy 3: Font Preloading with font-display: swap
Web fonts can block text rendering (FOIT - Flash of Invisible Text) up to 3 seconds on slow connections, delaying the LCP if the text and the LCP element. Two optimizations combine to eliminate this problem:
1. font-display: swap tells the browser to show the text immediately with the fallback font, then replace it with the web font when it is available. Eliminate FOIT.
/* In @font-face o nel parametro di Google Fonts */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap; /* Critico per LCP */
font-weight: 400 700;
}
/* Con Google Fonts: aggiungi &display=swap all'URL */
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"
rel="stylesheet"
/>
2. Preconnect + Preload reduces font discovery and download latency:
<!-- Preconnect: stabilisce la connessione con il font server in anticipo -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Preload: scarica il file font prima che il CSS venga parsato -->
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/inter-400.woff2"
crossorigin
/>
Strategy 4: Server-Side Rendering
The previous four strategies optimize resources; SSR optimizes the fundamental structure of the page. In a Single Page Application (SPA), the browser receives almost HTML empty and must download, parse and execute JavaScript to render the content. With SSR, the browser receives HTML already rendered: the LCP element is immediately visible without waiting for JavaScript.
The LCP improvement with SSR is typically 0.5-2 seconds:
- SPA without SSR: HTML shell → JS download (300-800ms) → JS execution (100-500ms) → API calls (200-800ms) → render → LCP
- With SSR: Full HTML → Instant LCP (download time only of the HTML and the server's TTFB)
Frameworks like Next.js, Angular Universal (SSR), Nuxt.js, and SvelteKit include native SSR.
For Angular, configuration is managed by angular.json and from the Express server:
// angular.json - abilita SSR
{
"projects": {
"app": {
"architect": {
"build": {
"configurations": {
"production": {
"server": "src/main.server.ts",
"prerender": false, // SSR on-demand
"ssr": true
}
}
}
}
}
}
}
Measuring LCP Improvement
After implementing optimizations, measure the improvement with these tools:
- Lighthouse CI: create baselines before optimizations and compare after. Run more runs (5+) and use the median to reduce variance.
- WebPageTest.org: get filmstrip and detailed loading waterfall on real devices and real connections.
- web-vitals.js in production: monitors users' real LCP P75 for 7-14 days after deployment.
import { onLCP } from 'web-vitals';
// Monitora LCP in produzione
onLCP({ reportAllChanges: true }, (metric) => {
// Invia solo il valore finale (quando la pagina e in background)
if (metric.entries.length > 0) {
navigator.sendBeacon('/api/vitals', JSON.stringify({
metric: 'LCP',
value: metric.value,
rating: metric.rating,
url: window.location.pathname,
device: navigator.connection?.effectiveType || 'unknown',
}));
}
});
Combined Impact of the Four Strategies
Apply all four strategies in combination on a typical SPA with hero image, web fonts and CSS bundled produces real improvements:
- Image preloading + fetchpriority: -300-600ms LCP
- Critical CSS inline: -200-500ms LCP (eliminates render-blocking)
- Font preloading + font-display:swap: -100-300ms LCP
- SSR: -500-2000ms LCP (depends on the complexity of the SPA)
The total result can take an LCP from 4-6 seconds to 1.5-2.5 seconds, going from "Poor" to "Good" consistently.
Conclusions
LCP optimization is an area where a few targeted interventions produce enormous impacts. 35% of sites with LCPs in the "Needs Improvement" or "Poor" range almost certainly have one or more of these four problems: hero image not preloaded, rendering-blocking CSS, font that delay text, or SPA without SSR.
The correct workflow is: 1) measure the real LCP with web-vitals.js, 2) identify the LCP element, 3) analyze the loading waterfall to find the bottleneck, 4) apply the relevant optimizations, 5) verify the improvement with Lighthouse CI and field data.







