Web Performance Optimization: Core Web Vitals and Beyond
Web Performance Optimization: Core Web Vitals and Beyond
Photo by Eden Constantino on Unsplash
Every 100 milliseconds of added load time costs you conversions. That's not a guess -- it's been measured repeatedly by Google, Amazon, Walmart, and everyone else with enough traffic to run the experiment. A page that takes 3 seconds to become interactive loses 53% of mobile visitors. You know this. The question is: what do you actually do about it?
Performance optimization is not about chasing a perfect Lighthouse score. It's about understanding which metrics matter for your users, diagnosing the specific bottlenecks in your application, and applying targeted fixes. A page that scores 100 on Lighthouse but feels janky when you scroll is worse than a page that scores 85 but responds instantly to every click.
Core Web Vitals: What They Measure and Why
Google's Core Web Vitals are three metrics that attempt to quantify the user experience of loading, interactivity, and visual stability. They affect search ranking, and more importantly, they correlate with real user satisfaction.
Largest Contentful Paint (LCP)
What it measures: How long it takes for the largest visible element (usually a hero image, heading, or text block) to render.
Target: Under 2.5 seconds on a median mobile connection.
What affects it:
- Server response time (TTFB)
- Render-blocking CSS and JavaScript
- Image loading strategy
- Font loading behavior
LCP is the metric most developers struggle with because it's affected by everything upstream. A slow server, unoptimized images, render-blocking scripts, and layout shifts all push LCP higher.
Interaction to Next Paint (INP)
What it measures: The responsiveness of the page to user interactions (clicks, taps, key presses). Specifically, it measures the time from when the user interacts to when the browser paints the next frame reflecting that interaction.
Target: Under 200 milliseconds.
What affects it:
- Long JavaScript tasks blocking the main thread
- Heavy event handlers
- Forced synchronous layouts
- Third-party script bloat
INP replaced First Input Delay (FID) in 2024 because FID only measured the first interaction. INP captures responsiveness throughout the entire page lifecycle, which is much more representative of actual user experience.
Cumulative Layout Shift (CLS)
What it measures: How much visible content shifts unexpectedly during page load. When an ad loads and pushes the article text down just as you're about to click a link -- that's layout shift.
Target: Under 0.1.
What affects it:
- Images and iframes without explicit dimensions
- Dynamically injected content (ads, embeds, cookie banners)
- Web fonts causing text reflow (FOIT/FOUT)
- Late-loading CSS
CLS is the easiest Core Web Vital to fix and the most common to neglect.
Diagnosing Performance: The Right Tools
Before optimizing anything, you need to know what's actually slow. Guessing leads to wasted effort -- optimizing an image when your bottleneck is a blocking script won't move the needle.
Lighthouse
Run Lighthouse in Chrome DevTools (Audits tab) or from the command line:
npx lighthouse https://your-site.com \
--output=html \
--output-path=./report.html \
--preset=desktop
For mobile testing (which is what Google uses for ranking):
npx lighthouse https://your-site.com \
--output=json \
--output-path=./report.json \
--form-factor=mobile \
--throttling.cpuSlowdownMultiplier=4
Lighthouse gives you lab data -- synthetic tests under controlled conditions. It's reproducible but doesn't reflect real user experience. Use it for diagnosing specific issues, not as your primary performance metric.
Chrome User Experience Report (CrUX)
CrUX provides real user data from Chrome browsers in the field. This is what Google actually uses for search ranking.
# Query CrUX API for your domain
curl 'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://your-site.com/",
"metrics": ["largest_contentful_paint", "interaction_to_next_paint", "cumulative_layout_shift"]
}'
Web Vitals Library
Measure Core Web Vitals from your actual users:
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
// Use sendBeacon for reliability during page unload
navigator.sendBeacon('/analytics', body);
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
This gives you percentile data from real users on real devices and real networks. A p75 LCP of 3.2 seconds means 25% of your users are having a bad experience.
Fixing LCP: The Loading Waterfall
LCP is almost always caused by a suboptimal loading waterfall. Here's the sequence that matters:
DNS → TCP → TLS → TTFB → HTML Parse → CSS Load → Render → LCP Element
Every step in this chain adds latency. The goal is to minimize the critical path.
Optimize Server Response Time (TTFB)
Target: Under 800ms for the initial HTML document.
# nginx: enable gzip compression
gzip on;
gzip_types text/html text/css application/javascript application/json;
gzip_min_length 256;
# Enable HTTP/2
listen 443 ssl http2;
# Add caching headers for static assets
location /static/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
If TTFB is consistently over 1 second, the problem is likely your server, not your frontend. Profile your server-side rendering, check database query performance, and consider adding a CDN.
Preload Critical Resources
Tell the browser what to fetch first:
<!-- Preload the LCP image -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<!-- Preload critical font -->
<link rel="preload" as="font" href="/fonts/inter-var.woff2"
type="font/woff2" crossorigin>
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://api.example.com">
<link rel="dns-prefetch" href="https://cdn.analytics.com">
The fetchpriority="high" attribute on your LCP image is one of the highest-impact single changes you can make. It tells the browser to prioritize that image over other resources discovered at the same time.
Image Optimization
Images are the LCP element on most pages. Get this right:
<!-- Use modern formats with fallbacks -->
<picture>
<source srcset="/hero.avif" type="image/avif">
<source srcset="/hero.webp" type="image/webp">
<img src="/hero.jpg" alt="Hero image"
width="1200" height="600"
loading="eager"
fetchpriority="high"
decoding="async">
</picture>
| Format | Compression | Browser Support | Best For |
|---|---|---|---|
| AVIF | Best (50% smaller than JPEG) | Chrome, Firefox, Safari 16.4+ | Photos, complex images |
| WebP | Good (25-35% smaller than JPEG) | All modern browsers | Universal fallback |
| JPEG | Baseline | Universal | Final fallback |
| SVG | N/A (vector) | Universal | Icons, illustrations, logos |
For responsive images, use srcset to serve appropriate sizes:
<img src="/hero-800.webp"
srcset="/hero-400.webp 400w,
/hero-800.webp 800w,
/hero-1200.webp 1200w,
/hero-1600.webp 1600w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Hero image"
width="1600" height="800">
Eliminate Render-Blocking Resources
CSS blocks rendering. JavaScript blocks HTML parsing. Together, they're the most common cause of slow LCP.
<!-- Critical CSS inlined in the head -->
<style>
/* Only styles needed for above-the-fold content */
body { margin: 0; font-family: system-ui; }
.hero { height: 60vh; display: grid; place-items: center; }
.nav { height: 60px; display: flex; align-items: center; }
</style>
<!-- Non-critical CSS loaded asynchronously -->
<link rel="preload" href="/styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
<!-- JavaScript deferred (doesn't block parsing) -->
<script src="/app.js" defer></script>
Fixing INP: Main Thread Discipline
INP measures how quickly the page responds to user input. If the main thread is busy running JavaScript, user interactions queue up and the page feels sluggish.
Break Up Long Tasks
Any JavaScript task over 50ms blocks the main thread. Break them into smaller chunks:
// Bad: one long task that blocks for 200ms
function processLargeDataset(items) {
items.forEach(item => {
expensiveOperation(item); // Blocks the main thread
});
}
// Good: yield to the main thread periodically
async function processLargeDataset(items) {
const CHUNK_SIZE = 50;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(item => expensiveOperation(item));
// Yield to let the browser process events and paint
await new Promise(resolve => setTimeout(resolve, 0));
}
}
The scheduler.yield() API (available in Chrome) is a better alternative:
async function processLargeDataset(items) {
for (const item of items) {
expensiveOperation(item);
if (navigator.scheduling?.isInputPending()) {
await scheduler.yield();
}
}
}
Move Computation Off the Main Thread
Web Workers run JavaScript on a separate thread, keeping the main thread free for user interaction:
// worker.js
self.addEventListener('message', (event) => {
const { data, operation } = event.data;
let result;
switch (operation) {
case 'sort':
result = data.sort((a, b) => a.value - b.value);
break;
case 'filter':
result = data.filter(item => item.active);
break;
case 'aggregate':
result = data.reduce((acc, item) => acc + item.value, 0);
break;
}
self.postMessage(result);
});
// main.js
const worker = new Worker('/worker.js');
worker.postMessage({
operation: 'sort',
data: largeDataset
});
worker.addEventListener('message', (event) => {
renderSortedData(event.data);
});
Audit Third-Party Scripts
Third-party scripts (analytics, chat widgets, A/B testing, ads) are the most common cause of poor INP. Audit them ruthlessly.
// Defer non-critical third-party scripts
function loadThirdParty(src) {
// Wait until the page is interactive and idle
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
const script = document.createElement('script');
script.src = src;
document.body.appendChild(script);
});
} else {
// Fallback: load after 3 seconds
setTimeout(() => {
const script = document.createElement('script');
script.src = src;
document.body.appendChild(script);
}, 3000);
}
}
// Load analytics after page is interactive
loadThirdParty('https://cdn.analytics.com/tracker.js');
Fixing CLS: Dimension Discipline
CLS is caused by content that shifts after initial render. The fix is almost always: reserve space before the content loads.
Always Set Image Dimensions
<!-- Bad: causes layout shift when image loads -->
<img src="/photo.webp" alt="Photo">
<!-- Good: browser reserves space immediately -->
<img src="/photo.webp" alt="Photo" width="800" height="600">
<!-- Also good: CSS aspect ratio -->
<style>
.image-container {
aspect-ratio: 4 / 3;
width: 100%;
}
</style>
Handle Font Loading
Web fonts cause layout shift in two ways: Flash of Invisible Text (FOIT) where text disappears until the font loads, or Flash of Unstyled Text (FOUT) where text renders in a fallback font then shifts when the custom font arrives.
/* Use font-display: swap for body text */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-display: swap;
}
/* Use size-adjust to minimize shift between fallback and custom font */
@font-face {
font-family: 'Inter-Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter-Fallback', system-ui, sans-serif;
}
Reserve Space for Dynamic Content
/* Ad slots: reserve exact dimensions */
.ad-slot {
min-height: 250px;
min-width: 300px;
}
/* Cookie banner: pin to viewport (doesn't cause shift) */
.cookie-banner {
position: fixed;
bottom: 0;
/* Fixed positioning doesn't affect layout flow */
}
Advanced Optimizations
Resource Hints and Priority
Modern browsers support granular resource priority control:
<!-- Speculation Rules API: prerender likely next pages -->
<script type="speculationrules">
{
"prerender": [{
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/logout" } }
]
},
"eagerness": "moderate"
}]
}
</script>
HTTP Caching Strategy
A layered caching strategy prevents unnecessary network requests:
| Resource Type | Cache-Control | Strategy |
|---|---|---|
| HTML | no-cache |
Always revalidate with server |
| CSS/JS (hashed filenames) | max-age=31536000, immutable |
Cache forever, bust with filename |
| Images (hashed) | max-age=31536000, immutable |
Cache forever |
| API responses | max-age=0, s-maxage=60 |
No browser cache, CDN caches 60s |
| Fonts | max-age=31536000, immutable |
Cache forever |
Code Splitting
Don't ship JavaScript the user doesn't need yet:
// React: lazy load routes
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Monitoring in Production
Lab testing catches obvious issues, but real user monitoring (RUM) catches the rest. Set up continuous performance monitoring:
// Send performance data to your analytics
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Long Animation Frames (LoAF) -- better than Long Tasks
if (entry.entryType === 'long-animation-frame') {
sendToAnalytics({
type: 'loaf',
duration: entry.duration,
blockingDuration: entry.blockingDuration,
scripts: entry.scripts.map(s => ({
sourceURL: s.sourceURL,
duration: s.duration,
})),
});
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
Set performance budgets in your CI pipeline:
{
"budgets": [
{
"resourceType": "script",
"budget": 300
},
{
"resourceType": "total",
"budget": 800
},
{
"metric": "largest-contentful-paint",
"budget": 2500
},
{
"metric": "interactive",
"budget": 3500
}
]
}
The Optimization Priority List
When you're staring at a slow site and don't know where to start, work through this list in order:
- Measure first: Run Lighthouse, check CrUX data, set up web-vitals reporting.
- Fix the server: If TTFB is over 1 second, no frontend optimization will save you.
- Optimize the LCP image: Format (AVIF/WebP), dimensions,
fetchpriority="high", preload. - Eliminate render-blocking resources: Inline critical CSS, defer scripts.
- Audit third-party scripts: Remove what you can, defer the rest.
- Set explicit dimensions: Images, iframes, ad slots, embeds.
- Code split: Load only the JavaScript needed for the current page.
- Cache everything: Hashed filenames + immutable cache headers.
Each of these targets a specific Core Web Vital, and the order ensures you fix the highest-impact issues first. Resist the urge to skip ahead to the interesting optimizations -- the basics are where most performance gains live.
