Web Performance Cheat Sheet: Core Web Vitals and Optimization
Web performance directly affects user retention, SEO rankings, and conversion rates. Google's Core Web Vitals have been ranking signals since 2021, and the metrics have evolved. This cheat sheet covers the current thresholds, the techniques that move the needle most, and the common mistakes that tank scores. Every optimization here has been verified against real-world data and current browser APIs.
Inspect HTTP response headers with our HTTP Headers Checker and optimize meta tags with our Meta Tag Generator.
Core Web Vitals 2026
Google updated Core Web Vitals in 2024, replacing First Input Delay (FID) with Interaction to Next Paint (INP). The three current metrics:
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | 2.5s – 4.0s | > 4.0s |
| INP (Interaction to Next Paint) | < 200ms | 200ms – 500ms | > 500ms |
| CLS (Cumulative Layout Shift) | < 0.1 | 0.1 – 0.25 | > 0.25 |
A page "passes" Core Web Vitals when all three metrics are in the "Good" range for at least 75% of page loads (75th percentile).
LCP: Largest Contentful Paint
LCP measures when the largest image or text block in the viewport is fully rendered. It's the closest metric to "how fast does the page feel loaded?"
Identify the LCP element:
// Use PerformanceObserver to find the LCP element
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP element:', lastEntry.element);
console.log('LCP time:', lastEntry.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// Or in DevTools: Performance tab > Web Vitals checkbox
LCP Optimization Techniques:
1. Preload the LCP image with fetchpriority="high"
<!-- In <head>, before any render-blocking resources -->
<link rel="preload"
as="image"
href="/hero-image.webp"
fetchpriority="high">
<!-- On the img tag itself -->
<img src="/hero-image.webp"
fetchpriority="high"
loading="eager"
decoding="async"
width="1200"
height="630"
alt="Hero image">
2. Never lazy-load the LCP image. The default for images with loading="lazy" defers loading until near the viewport—but the LCP image IS in the viewport and needs to load immediately.
3. Use a CDN for images. Origin server latency is the single largest contributor to LCP for image-heavy pages. A CDN with edge nodes serves images from milliseconds away.
4. Avoid LCP elements in iframes or background CSS images. CSS background images are discovered later in the rendering pipeline than <img> elements.
INP: Interaction to Next Paint
INP replaced FID in March 2024. While FID measured only the first interaction's delay, INP measures the worst interaction latency throughout the page's life (excluding outliers). An "interaction" is a click, tap, or keyboard input.
INP = Input Delay + Processing Time + Presentation Delay
- Input delay: Time from user interaction to start of JavaScript event handler. Caused by long tasks on the main thread (heavy scripts, forced reflows).
- Processing time: Time spent executing event handlers. Caused by expensive JavaScript.
- Presentation delay: Time from handler completion to paint. Caused by large DOM updates, complex CSS, and layout thrashing.
// Measure INP
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.interactionId > 0) {
console.log('Interaction:', entry.name);
console.log('INP component:', {
inputDelay: entry.processingStart - entry.startTime,
processingTime: entry.processingEnd - entry.processingStart,
presentationDelay: entry.duration - entry.processingEnd + entry.startTime
});
}
}
}).observe({ type: 'event', durationThreshold: 16, buffered: true });
// Break up long tasks to reduce input delay
// Instead of one 300ms task, use scheduler.yield()
async function processLargeList(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
// Yield to browser every 50 items to allow input events
if (i % 50 === 0) {
await scheduler.yield(); // Native API, Chrome 115+
// Fallback: await new Promise(r => setTimeout(r, 0));
}
}
}
CLS: Cumulative Layout Shift
CLS measures visual instability—elements jumping around as the page loads. Calculated as: impact fraction × distance fraction summed for unexpected shifts.
Common CLS causes and fixes:
<!-- CAUSE: Images without explicit dimensions -->
<img src="photo.jpg" alt="photo"> <!-- Layout shift when image loads! -->
<!-- FIX: Always specify width and height -->
<img src="photo.jpg" alt="photo" width="800" height="600">
<!-- Browser reserves space via aspect-ratio: 800/600 before image loads -->
<!-- Responsive images with maintained aspect ratio -->
<style>
img {
width: 100%;
height: auto; /* Maintains aspect ratio from width/height attributes */
}
</style>
<!-- CAUSE: Dynamic content injected above existing content (ads, banners) -->
<!-- FIX: Reserve space with min-height -->
<div class="ad-slot" style="min-height: 250px;">
<!-- Ad loads here -->
</div>
<!-- CAUSE: Web fonts causing FOUT (Flash of Unstyled Text) -->
<!-- FIX: Use font-display: optional to prevent layout shift -->
@font-face {
font-family: 'MyFont';
src: url('font.woff2') format('woff2');
font-display: optional; /* Don't shift if font not cached; use fallback */
}
/* Also size-adjust fallback to match custom font metrics */
@font-face {
font-family: 'MyFontFallback';
src: local('Arial');
size-adjust: 105%;
ascent-override: 90%;
descent-override: 20%;
}
Resource Hints
Resource hints tell the browser to take action on resources before they are discovered in the normal parsing flow:
<!-- preload: Fetch a resource needed for current page ASAP (high priority) -->
<link rel="preload" href="/critical.css" as="style">
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<link rel="preload" href="/font.woff2" as="font" type="font/woff2" crossorigin>
<!-- prefetch: Fetch a resource for likely FUTURE navigation (low priority) -->
<link rel="prefetch" href="/next-page.html">
<link rel="prefetch" href="/next-page-image.webp" as="image">
<!-- preconnect: Establish TCP/TLS to a third-party origin early -->
<!-- Use for origins you'll definitely request from -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- dns-prefetch: DNS lookup only (cheaper than preconnect) -->
<!-- Use for origins you MIGHT request from -->
<link rel="dns-prefetch" href="https://analytics.example.com">
Do not over-use preload—it competes with other critical resources and can hurt LCP if misused. Use it for LCP images, critical fonts, and render-blocking CSS discovered late.
Render-Blocking Scripts: defer vs async vs module
<!-- BLOCKS rendering - never do this for non-critical scripts -->
<script src="heavy-script.js"></script>
<!-- defer: Download in parallel, execute AFTER HTML parsed, in order -->
<!-- Best for: scripts that need the DOM, maintain execution order -->
<script defer src="main.js"></script>
<script defer src="analytics.js"></script>
<!-- async: Download in parallel, execute IMMEDIATELY when ready -->
<!-- Best for: independent scripts (analytics, ads) where order doesn't matter -->
<script async src="analytics.js"></script>
<!-- type=module: Deferred by default, strict mode, supports import/export -->
<!-- Equivalent to defer but also enables module imports -->
<script type="module" src="app.js"></script>
<!-- Inline critical scripts don't benefit from defer/async -->
<script>
// Inline critical JS runs synchronously (keep small!)
document.documentElement.classList.add('js');
</script>
Code Splitting with Dynamic Import()
// Instead of importing everything upfront:
// import { heavyModule } from './heavy.js'; // Loads immediately
// Load on demand:
button.addEventListener('click', async () => {
const { heavyModule } = await import('./heavy.js');
heavyModule.doWork();
});
// Route-based code splitting (React Router / Next.js example)
const HeavyPage = React.lazy(() => import('./HeavyPage'));
// Preload on hover (predictive loading)
link.addEventListener('mouseenter', () => {
import('./page.js'); // Start loading before click
});
// Webpack magic comments for chunk naming
const module = await import(/* webpackChunkName: "chart" */ './ChartComponent');
Compression: Brotli vs Gzip
Brotli compression (br) achieves approximately 20% better compression ratios than gzip for text assets (HTML, CSS, JavaScript). All modern browsers support Brotli. Configure your server to prefer Brotli:
# Nginx Brotli configuration (requires ngx_brotli module)
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml;
# Apache (mod_brotli)
AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/xml text/css application/javascript
# Express.js (shrink-ray-current package)
const shrinkRay = require('shrink-ray-current');
app.use(shrinkRay());
Image Format Comparison: WebP vs AVIF vs JPEG
| Format | Compression vs JPEG | Browser Support | Use Case |
|---|---|---|---|
| JPEG | Baseline | Universal | Fallback |
| WebP | 25–35% smaller | 97%+ (Chrome, Firefox, Safari 14+) | General use |
| AVIF | 50% smaller | Chrome 85+, Firefox 113+, Safari 16+ | Modern browsers |
| PNG | Lossless | Universal | Transparency, screenshots |
| SVG | Vector | Universal | Icons, logos, illustrations |
<!-- Use <picture> for format negotiation -->
<picture>
<source srcset="/hero.avif" type="image/avif">
<source srcset="/hero.webp" type="image/webp">
<img src="/hero.jpg"
alt="Hero"
width="1200"
height="630"
fetchpriority="high"
loading="eager">
</picture>
Font Subsetting with unicode-range
/* Load only Latin characters, not the full Unicode font */
@font-face {
font-family: 'MyFont';
src: url('font-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
/* Load extended Latin separately, only if needed */
@font-face {
font-family: 'MyFont';
src: url('font-latin-ext.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB,
U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
Google Fonts uses this technique extensively—the actual font files downloaded depend on the characters used on your page. Use pyftsubset (fonttools) for manual font subsetting to reduce file sizes by 80–90%.
HTTP/2 and Multiplexing
HTTP/2 multiplexes multiple requests over a single TCP connection, eliminating the head-of-line blocking problem from HTTP/1.1. This changes several performance recommendations:
- No longer needed with HTTP/2: Domain sharding, CSS sprites (for many small images), JS/CSS concatenation (code splitting is better)
- Still important with HTTP/2: Minimizing total byte transfer, compression, caching, avoiding render-blocking resources
- HTTP/3 (QUIC): Eliminates TCP head-of-line blocking entirely. Available in Nginx 1.25+, Cloudflare, and major CDNs.
Verify your server's response headers with our HTTP Headers Checker. Ensure you're seeing Content-Encoding: br for Brotli and proper caching headers. Build your site's meta tags correctly with our Meta Tag Generator and submit your sitemap with our Sitemap Generator.