Optimizing LCP (Largest Contentful Paint)
LCP — time for the largest element to appear in viewport. Goal: ≤ 2.5 seconds. Usually a hero image, H1, or large text block.
Diagnosis: what is the LCP element
DevTools → Performance → record page load → find LCP marker. Or through console:
new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
console.log('LCP element:', last.element);
console.log('LCP time:', last.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
Image optimization (main LCP element)
<!-- 1. Preload — most important step -->
<link rel="preload" as="image"
href="/images/hero.webp"
imagesrcset="/images/hero-640.webp 640w,
/images/hero-1280.webp 1280w,
/images/hero-1920.webp 1920w"
imagesizes="100vw"
fetchpriority="high">
<!-- 2. Image tag with fetchpriority -->
<img src="/images/hero.webp"
srcset="/images/hero-640.webp 640w,
/images/hero-1280.webp 1280w,
/images/hero-1920.webp 1920w"
sizes="100vw"
width="1920" height="1080"
alt="Image description"
fetchpriority="high"
loading="eager"
decoding="async">
loading="lazy" on LCP image — common mistake. Lazy loading delays loading, increasing LCP.
Accelerating TTFB (server)
LCP = TTFB + resource load time. Slow TTFB makes good LCP impossible.
// Laravel: Full Page Cache for public pages
// spatie/laravel-responsecache package or nginx fastcgi_cache
// Minimal FPC middleware
class CacheResponse
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->isMethod('GET') || auth()->check()) {
return $next($request);
}
$key = 'fpc:' . md5($request->fullUrl());
$cached = Cache::get($key);
if ($cached) {
return response($cached['body'], 200, $cached['headers'])
->header('X-Cache', 'HIT');
}
$response = $next($request);
if ($response->isOk()) {
Cache::put($key, [
'body' => $response->getContent(),
'headers' => ['Content-Type' => 'text/html; charset=UTF-8'],
], now()->addMinutes(5));
}
return $response;
}
}
# nginx fastcgi_cache
fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=FCGI:10m inactive=60m;
location ~ \.php$ {
fastcgi_cache FCGI;
fastcgi_cache_valid 200 5m;
fastcgi_cache_bypass $cookie_session $http_authorization;
fastcgi_no_cache $cookie_session $http_authorization;
add_header X-Cache $upstream_cache_status;
}
CSS — eliminate render-blocking
<!-- Critical CSS inline, rest async -->
<style>/* critical CSS for above-the-fold */</style>
<link rel="preload" href="/css/app.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/app.css"></noscript>
Critical CSS generation: Vite + critters plugin (inlines critical CSS automatically during build).
// vite.config.ts
import { critters } from 'critters';
export default defineConfig({
plugins: [
critters({ preload: 'swap', pruneSource: false })
]
});
Fonts
<!-- Preconnect to Google Fonts -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Preload most used font weight -->
<link rel="preload" as="font" type="font/woff2"
href="/fonts/inter-regular.woff2" crossorigin>
Background image as LCP
If LCP element is CSS background-image, browser can't preload it automatically. Better to replace with <img> or use fetchpriority via Speculation Rules (Chrome 121+):
// For background-image — explicit preload via JS
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'image';
link.href = '/images/hero-bg.webp';
document.head.appendChild(link);
Target metrics by page type
| Page type | Realistic LCP goal |
|---|---|
| Landing page | 1.5–2.0 s |
| E-commerce homepage | 2.0–2.5 s |
| Product card | 2.0–2.5 s |
| Blog article | 1.5–2.0 s |
Optimization time: 3–5 days including build setup, caching, and analysis.







