Files of Core web vitals optimization
wondelai/
Show the full text447 lines
Core Web Vitals Optimization
Core Web Vitals are Google's standardized performance metrics that measure real user experience. They directly impact search ranking and correlate strongly with user engagement, conversion rates, and bounce rates.
Table of Contents
- The Three Core Web Vitals
- LCP: Largest Contentful Paint
- INP: Interaction to Next Paint
- CLS: Cumulative Layout Shift
- Measuring Core Web Vitals
- Performance Budgets
- Debugging Workflow
- Quick Reference: Optimization Impact
The Three Core Web Vitals
| Metric | Measures | Good | Needs Improvement | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Loading performance | < 2.5s | 2.5s - 4.0s | > 4.0s |
| INP (Interaction to Next Paint) | Interactivity | < 200ms | 200ms - 500ms | > 500ms |
| CLS (Cumulative Layout Shift) | Visual stability | < 0.1 | 0.1 - 0.25 | > 0.25 |
Supporting metrics
| Metric | Measures | Target |
|---|---|---|
| TTFB (Time to First Byte) | Server responsiveness | < 800ms |
| FCP (First Contentful Paint) | Initial render speed | < 1.8s |
| TBT (Total Blocking Time) | Main thread availability (lab proxy for INP) | < 200ms |
LCP: Largest Contentful Paint
LCP measures when the largest visible content element finishes rendering. This is what users perceive as "the page has loaded."
What counts as the LCP element
<img>elements (including those inside<picture>)<video>poster images- Elements with
background-imagevia CSS - Block-level text elements (
<h1>,<p>, etc.)
The LCP element changes as the page loads. The final LCP element is the one that is largest when rendering stabilizes.
Common LCP problems
Slow server response (high TTFB): The browser cannot render anything until it receives the first byte of HTML.
Fixes:
- Add a CDN to reduce network latency
- Implement server-side caching (Redis, Varnish)
- Use streaming server-side rendering to send the HTML
<head>immediately - Optimize database queries and backend processing
- Use
103 Early Hintsto let the browser start fetching critical resources before the full response
Late-discovered LCP resource: The browser discovers the LCP image late in the rendering process (e.g., a CSS background image or a <img> tag deep in the HTML).
Fixes:
<!-- Preload the LCP image -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<!-- Or use fetchpriority directly -->
<img src="/hero.webp" fetchpriority="high" alt="Hero" width="1200" height="600">
Render-blocking resources: CSS and synchronous JavaScript delay rendering.
Fixes:
- Inline critical CSS in
<head> - Defer non-critical CSS loading
- Use
deferorasyncon scripts - Remove unused CSS (audit with Coverage panel)
Slow resource load time: The LCP image itself is too large or served from a slow origin.
Fixes:
- Compress images (AVIF, WebP)
- Serve responsive images with
srcset - Use a CDN for image delivery
- Set appropriate cache headers
Client-side rendering: SPAs that render content in JavaScript after the initial HTML load have inherently slow LCP.
Fixes:
- Use server-side rendering (SSR) or static site generation (SSG)
- Stream HTML with
Transfer-Encoding: chunked - Pre-render the above-fold content on the server
LCP optimization checklist
- TTFB under 800ms
- LCP resource discoverable in HTML source (not CSS or JS)
- LCP resource preloaded with
fetchpriority="high" - No render-blocking resources before LCP
- LCP image optimized (format, compression, responsive)
- LCP image served from CDN
- Critical CSS inlined; non-critical CSS deferred
INP: Interaction to Next Paint
INP measures the delay between a user interaction (click, tap, keypress) and the next visual update. It replaced FID (First Input Delay) as a Core Web Vital in March 2024.
Why INP matters more than FID: FID only measured the delay of the first interaction. INP measures all interactions throughout the page lifecycle and reports the single worst one — except on pages with many interactions, where it drops roughly one outlier per 50 interactions (approximating the 98th percentile for high-interaction pages). A page can have a good FID but terrible INP if JavaScript blocks the main thread during later interactions.
What causes poor INP
Long tasks on the main thread: Any JavaScript task that runs for more than 50ms blocks the browser from processing user input.
Excessive event handler work: Click handlers that perform heavy computation, DOM manipulation, or synchronous operations delay the visual response.
Layout thrashing: Reading layout properties (like offsetHeight) and then writing to the DOM in a loop forces the browser to recalculate layout repeatedly.
INP optimization strategies
Break up long tasks:
// BAD: One long task blocking the main thread
function processAllItems(items) {
items.forEach(item => heavyOperation(item));
}
// GOOD: Yield to the main thread between chunks
async function processAllItems(items) {
for (const item of items) {
heavyOperation(item);
// Yield to let the browser handle pending interactions
await scheduler.yield();
}
}
If scheduler.yield() is not available, use a polyfill pattern:
function yieldToMain() {
return new Promise(resolve => setTimeout(resolve, 0));
}
async function processAllItems(items) {
for (let i = 0; i < items.length; i++) {
heavyOperation(items[i]);
if (i % 10 === 0) await yieldToMain();
}
}
Defer non-urgent work:
// Use requestIdleCallback for work that does not need to happen immediately
requestIdleCallback(() => {
analytics.track('page_view');
prefetchNextPage();
});
Minimize event handler work:
// BAD: Heavy computation in click handler
button.addEventListener('click', () => {
const result = expensiveCalculation(); // 200ms
updateDOM(result); // 50ms
});
// GOOD: Show immediate feedback, defer heavy work
button.addEventListener('click', () => {
showLoadingState(); // 5ms - immediate feedback
requestAnimationFrame(() => {
const result = expensiveCalculation();
updateDOM(result);
hideLoadingState();
});
});
Avoid layout thrashing:
// BAD: Read-write-read-write forces repeated layout
elements.forEach(el => {
const height = el.offsetHeight; // Read (forces layout)
el.style.height = height * 2 + 'px'; // Write (invalidates layout)
});
// GOOD: Batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight); // All reads
elements.forEach((el, i) => {
el.style.height = heights[i] * 2 + 'px'; // All writes
});
Reduce JavaScript payload:
- Code-split: load only what the current page needs
- Tree-shake: remove unused exports
- Lazy-load non-critical modules
- Defer third-party scripts (analytics, widgets)
INP optimization checklist
- No JavaScript tasks longer than 50ms on the main thread
- Event handlers provide immediate visual feedback
- Non-urgent work deferred with
requestIdleCallbackorscheduler.yield() - No layout thrashing (batched reads and writes)
- JavaScript code-split and lazy-loaded
- Third-party scripts loaded async or deferred
CLS: Cumulative Layout Shift
CLS measures how much visible content shifts unexpectedly during the page lifecycle. Layout shifts destroy user trust -- users click the wrong button, lose their reading position, or experience visual chaos.
What causes layout shifts
Images without dimensions: When an image loads, it pushes surrounding content down if no space was reserved.
Dynamic content injection: Ads, banners, cookie notices, and lazy-loaded content that inserts above existing content.
Web fonts causing text reflow: When a web font loads and replaces a fallback font with different metrics, text reflows and shifts surrounding elements.
Dynamic content resizing: Accordions, tab panels, or carousels that change height.
CLS prevention strategies
Always set image dimensions:
<!-- Explicit dimensions reserve space -->
<img src="photo.jpg" width="800" height="600" alt="Photo">
<!-- CSS aspect-ratio works too -->
<style>
.hero-img { aspect-ratio: 16 / 9; width: 100%; }
</style>
Reserve space for dynamic content:
/* Reserve space for an ad slot */
.ad-slot {
min-height: 250px;
background: #f0f0f0;
}
/* Reserve space for a cookie banner */
.cookie-banner-placeholder {
min-height: 80px;
}
Use font-display: optional for strict CLS prevention:
@font-face {
font-family: 'Custom Font';
src: url('font.woff2') format('woff2');
font-display: optional; /* Uses font only if already cached */
}
Alternatively, use font-display: swap with font metric overrides to match fallback metrics:
@font-face {
font-family: 'Custom Font';
src: url('font.woff2') format('woff2');
font-display: swap;
size-adjust: 105%;
ascent-override: 95%;
descent-override: 22%;
line-gap-override: 0%;
}
Insert dynamic content below the viewport or with explicit reservations:
// BAD: Inserting a banner at the top pushes everything down
document.body.prepend(bannerElement);
// GOOD: Use CSS transforms that don't trigger layout
// Or insert in a reserved slot with min-height already set
document.querySelector('.banner-slot').appendChild(bannerElement);
Use CSS contain for independent layout regions:
.widget {
contain: layout; /* Layout changes inside don't affect outside */
}
CLS optimization checklist
- All images and videos have explicit width and height attributes
- Ad slots and dynamic content areas have reserved minimum heights
- Web fonts use
font-display: swaporoptionalwith metric overrides - No content inserted above existing visible content
- CSS animations use
transformandopacityonly (composited properties) - Embeds (iframes, widgets) have explicit dimensions
Measuring Core Web Vitals
Lab tools (synthetic testing)
| Tool | Metrics | Use case |
|---|---|---|
| Lighthouse (Chrome DevTools) | LCP, TBT, CLS | Development, auditing |
| WebPageTest | All vitals + filmstrip | Detailed analysis |
| PageSpeed Insights | Lab + field data | Quick overview |
Field tools (real user monitoring)
| Tool | Data source | Use case |
|---|---|---|
| Chrome User Experience Report (CrUX) | Chrome users | Public dataset, search ranking data |
| Google Search Console | CrUX data per URL/group | SEO impact monitoring |
| web-vitals library | Your users | Custom RUM implementation |
Implementing RUM with the web-vitals library
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'
id: metric.id,
navigationType: metric.navigationType,
});
// Use sendBeacon for reliable delivery
navigator.sendBeacon('/analytics', body);
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Lab vs. field data
| Aspect | Lab (synthetic) | Field (RUM) |
|---|---|---|
| Environment | Controlled (specific device, network) | Real user conditions |
| INP measurement | Uses TBT as proxy | True INP from real interactions |
| CLS measurement | Page load only | Full page lifecycle |
| Reproducibility | High | Low (varies by user) |
| Use for | Debugging, development | Monitoring, search ranking |
Always prioritize field data for understanding real performance. Lab data is useful for debugging but does not capture the diversity of real-world conditions.
Performance Budgets
Performance budgets set thresholds that prevent regressions:
// Example budget in a CI/CD pipeline
const budgets = {
lcp: 2500, // ms
inp: 200, // ms (use TBT in lab)
cls: 0.1, // score
ttfb: 800, // ms
totalJS: 300, // KB (compressed)
totalCSS: 50, // KB (compressed)
totalImages: 500, // KB
totalFonts: 200, // KB
};
Enforcing budgets
Lighthouse CI: Run Lighthouse in CI and fail builds that exceed budgets:
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }]
}
}
}
}
Bundle size monitoring: Tools like bundlesize, size-limit, or Webpack's performance.maxAssetSize can catch JavaScript bloat before it ships.
Debugging Workflow
When Core Web Vitals are poor, follow this systematic approach:
1. Identify the problem metric
Check CrUX data in Search Console or PageSpeed Insights.
2. Reproduce in lab
Use Lighthouse or WebPageTest with throttled conditions matching your users.
3. Diagnose root cause
For poor LCP:
- Check TTFB (server issue?)
- Check resource waterfall (late-discovered LCP resource?)
- Check render-blocking resources (too much blocking CSS/JS?)
- Check resource size (unoptimized images?)
For poor INP:
- Record a Performance trace in DevTools
- Identify long tasks (red flags in the flame chart)
- Find the interaction that triggered the long task
- Determine which script/function caused the blocking
For poor CLS:
- Use the Layout Shift Regions overlay in DevTools (Rendering panel)
- Check for images without dimensions
- Look for late-injected content (ads, banners)
- Test font loading behavior
4. Fix and verify
Implement the fix, measure in lab, deploy, and verify with field data (allow 28 days for CrUX data to reflect changes).
Quick Reference: Optimization Impact
| Optimization | LCP | INP | CLS | Effort |
|---|---|---|---|---|
| Add CDN | High | -- | -- | Low |
| Preload LCP resource | High | -- | -- | Low |
| Inline critical CSS | High | -- | -- | Medium |
| Code-split JavaScript | Medium | High | -- | Medium |
| Set image dimensions | -- | -- | High | Low |
Use font-display: swap |
-- | -- | Medium | Low |
| Defer third-party scripts | Medium | High | Medium | Low |
| Implement streaming SSR | High | Medium | -- | High |
| Break up long tasks | -- | High | -- | Medium |
| Reserve ad slot space | -- | -- | High | Low |
Optimizing Core Web Vitals is not a one-time task -- it requires continuous monitoring and a performance-aware development culture that treats metrics regressions as bugs.
| 1 | # Core Web Vitals Optimization |
| 2 | |
| 3 | Core Web Vitals are Google's standardized performance metrics that measure real user experience. They directly impact search ranking and correlate strongly with user engagement, conversion rates, and bounce rates. |
| 4 | |
| 5 | |
| 6 | ## Table of Contents |
| 7 | [The Three Core Web Vitals] |
| 8 | [LCP: Largest Contentful Paint] |
| 9 | [INP: Interaction to Next Paint] |
| 10 | [CLS: Cumulative Layout Shift] |
| 11 | [Measuring Core Web Vitals] |
| 12 | [Performance Budgets] |
| 13 | [Debugging Workflow] |
| 14 | [Quick Reference: Optimization Impact] |
| 15 | |
| 16 | |
| 17 | |
| 18 | ## The Three Core Web Vitals |
| 19 | |
| 20 | | Metric | Measures | Good | Needs Improvement | Poor | |
| 21 | |--------|----------|------|-------------------|------| |
| 22 | | **LCP** (Largest Contentful Paint) | Loading performance | < 2.5s | 2.5s - 4.0s | > 4.0s | |
| 23 | | **INP** (Interaction to Next Paint) | Interactivity | < 200ms | 200ms - 500ms | > 500ms | |
| 24 | | **CLS** (Cumulative Layout Shift) | Visual stability | < 0.1 | 0.1 - 0.25 | > 0.25 | |
| 25 | |
| 26 | ### Supporting metrics |
| 27 | |
| 28 | | Metric | Measures | Target | |
| 29 | |--------|----------|--------| |
| 30 | | **TTFB** (Time to First Byte) | Server responsiveness | < 800ms | |
| 31 | | **FCP** (First Contentful Paint) | Initial render speed | < 1.8s | |
| 32 | | **TBT** (Total Blocking Time) | Main thread availability (lab proxy for INP) | < 200ms | |
| 33 | |
| 34 | ## LCP: Largest Contentful Paint |
| 35 | |
| 36 | LCP measures when the largest visible content element finishes rendering. This is what users perceive as "the page has loaded." |
| 37 | |
| 38 | ### What counts as the LCP element |
| 39 | |
| 40 | `<img>` elements (including those inside `<picture>`) |
| 41 | `<video>` poster images |
| 42 | Elements with `background-image` via CSS |
| 43 | Block-level text elements (`<h1>`, `<p>`, etc.) |
| 44 | |
| 45 | The LCP element changes as the page loads. The final LCP element is the one that is largest when rendering stabilizes. |
| 46 | |
| 47 | ### Common LCP problems |
| 48 | |
| 49 | **Slow server response (high TTFB):** The browser cannot render anything until it receives the first byte of HTML. |
| 50 | |
| 51 | Fixes: |
| 52 | Add a CDN to reduce network latency |
| 53 | Implement server-side caching (Redis, Varnish) |
| 54 | Use streaming server-side rendering to send the HTML `<head>` immediately |
| 55 | Optimize database queries and backend processing |
| 56 | Use `103 Early Hints` to let the browser start fetching critical resources before the full response |
| 57 | |
| 58 | **Late-discovered LCP resource:** The browser discovers the LCP image late in the rendering process (e.g., a CSS background image or a `<img>` tag deep in the HTML). |
| 59 | |
| 60 | Fixes: |
| 61 | |
| 62 | <!-- Preload the LCP image --> |
| 63 | <link rel="preload" href="/hero.webp" as="image" fetchpriority="high"> |
| 64 | |
| 65 | <!-- Or use fetchpriority directly --> |
| 66 | <img src="/hero.webp" fetchpriority="high" alt="Hero" width="1200" height="600"> |
| 67 | |
| 68 | |
| 69 | **Render-blocking resources:** CSS and synchronous JavaScript delay rendering. |
| 70 | |
| 71 | Fixes: |
| 72 | Inline critical CSS in `<head>` |
| 73 | Defer non-critical CSS loading |
| 74 | Use `defer` or `async` on scripts |
| 75 | Remove unused CSS (audit with Coverage panel) |
| 76 | |
| 77 | **Slow resource load time:** The LCP image itself is too large or served from a slow origin. |
| 78 | |
| 79 | Fixes: |
| 80 | Compress images (AVIF, WebP) |
| 81 | Serve responsive images with `srcset` |
| 82 | Use a CDN for image delivery |
| 83 | Set appropriate cache headers |
| 84 | |
| 85 | **Client-side rendering:** SPAs that render content in JavaScript after the initial HTML load have inherently slow LCP. |
| 86 | |
| 87 | Fixes: |
| 88 | Use server-side rendering (SSR) or static site generation (SSG) |
| 89 | Stream HTML with `Transfer-Encoding: chunked` |
| 90 | Pre-render the above-fold content on the server |
| 91 | |
| 92 | ### LCP optimization checklist |
| 93 | |
| 94 | [ ] TTFB under 800ms |
| 95 | [ ] LCP resource discoverable in HTML source (not CSS or JS) |
| 96 | [ ] LCP resource preloaded with `fetchpriority="high"` |
| 97 | [ ] No render-blocking resources before LCP |
| 98 | [ ] LCP image optimized (format, compression, responsive) |
| 99 | [ ] LCP image served from CDN |
| 100 | [ ] Critical CSS inlined; non-critical CSS deferred |
| 101 | |
| 102 | ## INP: Interaction to Next Paint |
| 103 | |
| 104 | INP measures the delay between a user interaction (click, tap, keypress) and the next visual update. It replaced FID (First Input Delay) as a Core Web Vital in March 2024. |
| 105 | |
| 106 | **Why INP matters more than FID:** FID only measured the delay of the first interaction. INP measures all interactions throughout the page lifecycle and reports the single worst one — except on pages with many interactions, where it drops roughly one outlier per 50 interactions (approximating the 98th percentile for high-interaction pages). A page can have a good FID but terrible INP if JavaScript blocks the main thread during later interactions. |
| 107 | |
| 108 | ### What causes poor INP |
| 109 | |
| 110 | **Long tasks on the main thread:** Any JavaScript task that runs for more than 50ms blocks the browser from processing user input. |
| 111 | |
| 112 | **Excessive event handler work:** Click handlers that perform heavy computation, DOM manipulation, or synchronous operations delay the visual response. |
| 113 | |
| 114 | **Layout thrashing:** Reading layout properties (like `offsetHeight`) and then writing to the DOM in a loop forces the browser to recalculate layout repeatedly. |
| 115 | |
| 116 | ### INP optimization strategies |
| 117 | |
| 118 | **Break up long tasks:** |
| 119 | |
| 120 | |
| 121 | // BAD: One long task blocking the main thread |
| 122 | function processAllItems(items) { |
| 123 | items.forEach(item => heavyOperation(item)); |
| 124 | } |
| 125 | |
| 126 | // GOOD: Yield to the main thread between chunks |
| 127 | async function processAllItems(items) { |
| 128 | for (const item of items) { |
| 129 | heavyOperation(item); |
| 130 | // Yield to let the browser handle pending interactions |
| 131 | await scheduler.yield(); |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | |
| 136 | If `scheduler.yield()` is not available, use a polyfill pattern: |
| 137 | |
| 138 | |
| 139 | function yieldToMain() { |
| 140 | return new Promise(resolve => setTimeout(resolve, 0)); |
| 141 | } |
| 142 | |
| 143 | async function processAllItems(items) { |
| 144 | for (let i = 0; i < items.length; i++) { |
| 145 | heavyOperation(items[i]); |
| 146 | if (i % 10 === 0) await yieldToMain(); |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | |
| 151 | **Defer non-urgent work:** |
| 152 | |
| 153 | |
| 154 | // Use requestIdleCallback for work that does not need to happen immediately |
| 155 | requestIdleCallback(() => { |
| 156 | analytics.track('page_view'); |
| 157 | prefetchNextPage(); |
| 158 | }); |
| 159 | |
| 160 | |
| 161 | **Minimize event handler work:** |
| 162 | |
| 163 | |
| 164 | // BAD: Heavy computation in click handler |
| 165 | button.addEventListener('click', () => { |
| 166 | const result = expensiveCalculation(); // 200ms |
| 167 | updateDOM(result); // 50ms |
| 168 | }); |
| 169 | |
| 170 | // GOOD: Show immediate feedback, defer heavy work |
| 171 | button.addEventListener('click', () => { |
| 172 | showLoadingState(); // 5ms - immediate feedback |
| 173 | requestAnimationFrame(() => { |
| 174 | const result = expensiveCalculation(); |
| 175 | updateDOM(result); |
| 176 | hideLoadingState(); |
| 177 | }); |
| 178 | }); |
| 179 | |
| 180 | |
| 181 | **Avoid layout thrashing:** |
| 182 | |
| 183 | |
| 184 | // BAD: Read-write-read-write forces repeated layout |
| 185 | elements.forEach(el => { |
| 186 | const height = el.offsetHeight; // Read (forces layout) |
| 187 | el.style.height = height * 2 + 'px'; // Write (invalidates layout) |
| 188 | }); |
| 189 | |
| 190 | // GOOD: Batch reads, then batch writes |
| 191 | const heights = elements.map(el => el.offsetHeight); // All reads |
| 192 | elements.forEach((el, i) => { |
| 193 | el.style.height = heights[i] * 2 + 'px'; // All writes |
| 194 | }); |
| 195 | |
| 196 | |
| 197 | **Reduce JavaScript payload:** |
| 198 | Code-split: load only what the current page needs |
| 199 | Tree-shake: remove unused exports |
| 200 | Lazy-load non-critical modules |
| 201 | Defer third-party scripts (analytics, widgets) |
| 202 | |
| 203 | ### INP optimization checklist |
| 204 | |
| 205 | [ ] No JavaScript tasks longer than 50ms on the main thread |
| 206 | [ ] Event handlers provide immediate visual feedback |
| 207 | [ ] Non-urgent work deferred with `requestIdleCallback` or `scheduler.yield()` |
| 208 | [ ] No layout thrashing (batched reads and writes) |
| 209 | [ ] JavaScript code-split and lazy-loaded |
| 210 | [ ] Third-party scripts loaded async or deferred |
| 211 | |
| 212 | ## CLS: Cumulative Layout Shift |
| 213 | |
| 214 | CLS measures how much visible content shifts unexpectedly during the page lifecycle. Layout shifts destroy user trust -- users click the wrong button, lose their reading position, or experience visual chaos. |
| 215 | |
| 216 | ### What causes layout shifts |
| 217 | |
| 218 | **Images without dimensions:** When an image loads, it pushes surrounding content down if no space was reserved. |
| 219 | |
| 220 | **Dynamic content injection:** Ads, banners, cookie notices, and lazy-loaded content that inserts above existing content. |
| 221 | |
| 222 | **Web fonts causing text reflow:** When a web font loads and replaces a fallback font with different metrics, text reflows and shifts surrounding elements. |
| 223 | |
| 224 | **Dynamic content resizing:** Accordions, tab panels, or carousels that change height. |
| 225 | |
| 226 | ### CLS prevention strategies |
| 227 | |
| 228 | **Always set image dimensions:** |
| 229 | |
| 230 | |
| 231 | <!-- Explicit dimensions reserve space --> |
| 232 | <img src="photo.jpg" width="800" height="600" alt="Photo"> |
| 233 | |
| 234 | <!-- CSS aspect-ratio works too --> |
| 235 | <style> |
| 236 | .hero-img { aspect-ratio: 16 / 9; width: 100%; } |
| 237 | </style> |
| 238 | |
| 239 | |
| 240 | **Reserve space for dynamic content:** |
| 241 | |
| 242 | |
| 243 | /* Reserve space for an ad slot */ |
| 244 | .ad-slot { |
| 245 | min-height: 250px; |
| 246 | background: #f0f0f0; |
| 247 | } |
| 248 | |
| 249 | /* Reserve space for a cookie banner */ |
| 250 | .cookie-banner-placeholder { |
| 251 | min-height: 80px; |
| 252 | } |
| 253 | |
| 254 | |
| 255 | **Use `font-display: optional` for strict CLS prevention:** |
| 256 | |
| 257 | |
| 258 | @font-face { |
| 259 | font-family: 'Custom Font'; |
| 260 | src: url('font.woff2') format('woff2'); |
| 261 | font-display: optional; /* Uses font only if already cached */ |
| 262 | } |
| 263 | |
| 264 | |
| 265 | Alternatively, use `font-display: swap` with font metric overrides to match fallback metrics: |
| 266 | |
| 267 | |
| 268 | @font-face { |
| 269 | font-family: 'Custom Font'; |
| 270 | src: url('font.woff2') format('woff2'); |
| 271 | font-display: swap; |
| 272 | size-adjust: 105%; |
| 273 | ascent-override: 95%; |
| 274 | descent-override: 22%; |
| 275 | line-gap-override: 0%; |
| 276 | } |
| 277 | |
| 278 | |
| 279 | **Insert dynamic content below the viewport or with explicit reservations:** |
| 280 | |
| 281 | |
| 282 | // BAD: Inserting a banner at the top pushes everything down |
| 283 | document.body.prepend(bannerElement); |
| 284 | |
| 285 | // GOOD: Use CSS transforms that don't trigger layout |
| 286 | // Or insert in a reserved slot with min-height already set |
| 287 | document.querySelector('.banner-slot').appendChild(bannerElement); |
| 288 | |
| 289 | |
| 290 | **Use CSS `contain` for independent layout regions:** |
| 291 | |
| 292 | |
| 293 | .widget { |
| 294 | contain: layout; /* Layout changes inside don't affect outside */ |
| 295 | } |
| 296 | |
| 297 | |
| 298 | ### CLS optimization checklist |
| 299 | |
| 300 | [ ] All images and videos have explicit width and height attributes |
| 301 | [ ] Ad slots and dynamic content areas have reserved minimum heights |
| 302 | [ ] Web fonts use `font-display: swap` or `optional` with metric overrides |
| 303 | [ ] No content inserted above existing visible content |
| 304 | [ ] CSS animations use `transform` and `opacity` only (composited properties) |
| 305 | [ ] Embeds (iframes, widgets) have explicit dimensions |
| 306 | |
| 307 | ## Measuring Core Web Vitals |
| 308 | |
| 309 | ### Lab tools (synthetic testing) |
| 310 | |
| 311 | | Tool | Metrics | Use case | |
| 312 | |------|---------|----------| |
| 313 | | **Lighthouse** (Chrome DevTools) | LCP, TBT, CLS | Development, auditing | |
| 314 | | **WebPageTest** | All vitals + filmstrip | Detailed analysis | |
| 315 | | **PageSpeed Insights** | Lab + field data | Quick overview | |
| 316 | |
| 317 | ### Field tools (real user monitoring) |
| 318 | |
| 319 | | Tool | Data source | Use case | |
| 320 | |------|------------|----------| |
| 321 | | **Chrome User Experience Report (CrUX)** | Chrome users | Public dataset, search ranking data | |
| 322 | | **Google Search Console** | CrUX data per URL/group | SEO impact monitoring | |
| 323 | | **web-vitals library** | Your users | Custom RUM implementation | |
| 324 | |
| 325 | ### Implementing RUM with the web-vitals library |
| 326 | |
| 327 | |
| 328 | import { onLCP, onINP, onCLS } from 'web-vitals'; |
| 329 | |
| 330 | function sendToAnalytics(metric) { |
| 331 | const body = JSON.stringify({ |
| 332 | name: metric.name, |
| 333 | value: metric.value, |
| 334 | rating: metric.rating, // 'good', 'needs-improvement', 'poor' |
| 335 | id: metric.id, |
| 336 | navigationType: metric.navigationType, |
| 337 | }); |
| 338 | // Use sendBeacon for reliable delivery |
| 339 | navigator.sendBeacon('/analytics', body); |
| 340 | } |
| 341 | |
| 342 | onLCP(sendToAnalytics); |
| 343 | onINP(sendToAnalytics); |
| 344 | onCLS(sendToAnalytics); |
| 345 | |
| 346 | |
| 347 | ### Lab vs. field data |
| 348 | |
| 349 | | Aspect | Lab (synthetic) | Field (RUM) | |
| 350 | |--------|----------------|-------------| |
| 351 | | **Environment** | Controlled (specific device, network) | Real user conditions | |
| 352 | | **INP measurement** | Uses TBT as proxy | True INP from real interactions | |
| 353 | | **CLS measurement** | Page load only | Full page lifecycle | |
| 354 | | **Reproducibility** | High | Low (varies by user) | |
| 355 | | **Use for** | Debugging, development | Monitoring, search ranking | |
| 356 | |
| 357 | **Always prioritize field data** for understanding real performance. Lab data is useful for debugging but does not capture the diversity of real-world conditions. |
| 358 | |
| 359 | ## Performance Budgets |
| 360 | |
| 361 | Performance budgets set thresholds that prevent regressions: |
| 362 | |
| 363 | |
| 364 | // Example budget in a CI/CD pipeline |
| 365 | const budgets = { |
| 366 | lcp: 2500, // ms |
| 367 | inp: 200, // ms (use TBT in lab) |
| 368 | cls: 0.1, // score |
| 369 | ttfb: 800, // ms |
| 370 | totalJS: 300, // KB (compressed) |
| 371 | totalCSS: 50, // KB (compressed) |
| 372 | totalImages: 500, // KB |
| 373 | totalFonts: 200, // KB |
| 374 | }; |
| 375 | |
| 376 | |
| 377 | ### Enforcing budgets |
| 378 | |
| 379 | **Lighthouse CI:** Run Lighthouse in CI and fail builds that exceed budgets: |
| 380 | |
| 381 | |
| 382 | { |
| 383 | "ci": { |
| 384 | "assert": { |
| 385 | "assertions": { |
| 386 | "categories:performance": ["error", { "minScore": 0.9 }], |
| 387 | "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }], |
| 388 | "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }], |
| 389 | "total-blocking-time": ["error", { "maxNumericValue": 200 }] |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | |
| 396 | **Bundle size monitoring:** Tools like `bundlesize`, `size-limit`, or Webpack's `performance.maxAssetSize` can catch JavaScript bloat before it ships. |
| 397 | |
| 398 | ## Debugging Workflow |
| 399 | |
| 400 | When Core Web Vitals are poor, follow this systematic approach: |
| 401 | |
| 402 | ### 1. Identify the problem metric |
| 403 | Check CrUX data in Search Console or PageSpeed Insights. |
| 404 | |
| 405 | ### 2. Reproduce in lab |
| 406 | Use Lighthouse or WebPageTest with throttled conditions matching your users. |
| 407 | |
| 408 | ### 3. Diagnose root cause |
| 409 | |
| 410 | **For poor LCP:** |
| 411 | Check TTFB (server issue?) |
| 412 | Check resource waterfall (late-discovered LCP resource?) |
| 413 | Check render-blocking resources (too much blocking CSS/JS?) |
| 414 | Check resource size (unoptimized images?) |
| 415 | |
| 416 | **For poor INP:** |
| 417 | Record a Performance trace in DevTools |
| 418 | Identify long tasks (red flags in the flame chart) |
| 419 | Find the interaction that triggered the long task |
| 420 | Determine which script/function caused the blocking |
| 421 | |
| 422 | **For poor CLS:** |
| 423 | Use the Layout Shift Regions overlay in DevTools (Rendering panel) |
| 424 | Check for images without dimensions |
| 425 | Look for late-injected content (ads, banners) |
| 426 | Test font loading behavior |
| 427 | |
| 428 | ### 4. Fix and verify |
| 429 | Implement the fix, measure in lab, deploy, and verify with field data (allow 28 days for CrUX data to reflect changes). |
| 430 | |
| 431 | ## Quick Reference: Optimization Impact |
| 432 | |
| 433 | | Optimization | LCP | INP | CLS | Effort | |
| 434 | |-------------|-----|-----|-----|--------| |
| 435 | | Add CDN | High | -- | -- | Low | |
| 436 | | Preload LCP resource | High | -- | -- | Low | |
| 437 | | Inline critical CSS | High | -- | -- | Medium | |
| 438 | | Code-split JavaScript | Medium | High | -- | Medium | |
| 439 | | Set image dimensions | -- | -- | High | Low | |
| 440 | | Use `font-display: swap` | -- | -- | Medium | Low | |
| 441 | | Defer third-party scripts | Medium | High | Medium | Low | |
| 442 | | Implement streaming SSR | High | Medium | -- | High | |
| 443 | | Break up long tasks | -- | High | -- | Medium | |
| 444 | | Reserve ad slot space | -- | -- | High | Low | |
| 445 | |
| 446 | Optimizing Core Web Vitals is not a one-time task -- it requires continuous monitoring and a performance-aware development culture that treats metrics regressions as bugs. |
| 447 |
Discussion
Alternatives
Browse more free Claude skills or everything in Marketing.