Web Performance & Accessibility
Core Web Vitals Deep Dive
Core Web Vitals are Google's standardized metrics for measuring real-world user experience. Every frontend interview at a performance-conscious company will test your knowledge of these metrics, their thresholds, and how to optimize them.
The Three Core Web Vitals
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.5s | > 2.5s to ≤ 4.0s | > 4.0s |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | > 0.1 to ≤ 0.25 | > 0.25 |
| INP (Interaction to Next Paint) | ≤ 200ms | > 200ms to ≤ 500ms | > 500ms |
LCP — Largest Contentful Paint
LCP measures how quickly the largest visible content element renders. This is typically a hero image, heading block, or large text paragraph.
What Elements Count for LCP
<img>elements<image>inside<svg><video>poster images- Elements with
background-imageloaded via CSS - Block-level text elements (
<h1>,<p>, etc.)
LCP Optimization Strategies
1. Critical CSS Extraction
Extract CSS needed for above-the-fold content and inline it in the <head>:
<head>
<!-- Inline critical CSS for immediate rendering -->
<style>
.hero { display: flex; align-items: center; min-height: 60vh; }
.hero-title { font-size: 3rem; font-weight: 700; }
</style>
<!-- Load remaining CSS asynchronously -->
<link rel="preload" href="/styles/main.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
</head>
2. Font Loading Optimization
Fonts block rendering by default. Use font-display: swap to show fallback text immediately:
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* Show fallback font until custom loads */
}
Preload the font file so it starts downloading early:
<link rel="preload" href="/fonts/custom.woff2" as="font"
type="font/woff2" crossorigin>
3. Image Optimization
Use modern formats, responsive sizing, and lazy loading for offscreen images:
<!-- Hero image: preload because it is the LCP element -->
<link rel="preload" as="image" href="/hero.avif"
imagesrcset="/hero-400.avif 400w, /hero-800.avif 800w, /hero-1200.avif 1200w"
imagesizes="100vw">
<!-- Responsive image with modern formats -->
<picture>
<source srcset="/hero-400.avif 400w, /hero-800.avif 800w, /hero-1200.avif 1200w"
sizes="100vw" type="image/avif">
<source srcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
sizes="100vw" type="image/webp">
<img src="/hero-800.jpg" alt="Hero image description"
width="1200" height="600"
fetchpriority="high">
</picture>
<!-- Below-fold images: lazy load -->
<img src="/feature.webp" alt="Feature screenshot"
loading="lazy" width="600" height="400">
4. Preload Key Resources
Tell the browser to prioritize resources critical for LCP:
<link rel="preload" href="/api/hero-data" as="fetch" crossorigin>
<link rel="preconnect" href="https://cdn.example.com">
<link rel="dns-prefetch" href="https://analytics.example.com">
CLS — Cumulative Layout Shift
CLS measures visual stability. Every time a visible element shifts position unexpectedly, it contributes to the CLS score.
Common Causes of Layout Shifts
1. Images Without Dimensions
<!-- BAD: No dimensions, causes layout shift when image loads -->
<img src="/photo.jpg" alt="Photo">
<!-- GOOD: Explicit dimensions reserve space -->
<img src="/photo.jpg" alt="Photo" width="800" height="600">
<!-- GOOD: CSS aspect-ratio for responsive images -->
<style>
.responsive-img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
</style>
<img src="/photo.jpg" alt="Photo" class="responsive-img">
2. Dynamically Injected Content
/* Reserve space for dynamic content like ads or banners */
.ad-slot {
min-height: 250px; /* Reserve the expected ad height */
contain: layout; /* Prevent layout changes from affecting siblings */
}
.notification-banner {
/* Use transform instead of changing height/margin */
transform: translateY(-100%);
transition: transform 0.3s ease;
}
.notification-banner.visible {
transform: translateY(0);
}
3. Web Fonts Causing FOIT/FOUT
- FOIT (Flash of Invisible Text): browser hides text until the font loads
- FOUT (Flash of Unstyled Text): browser shows fallback font, then swaps
/* Minimize FOUT by matching fallback font metrics */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
/* Use size-adjust to minimize layout shift from font swap */
size-adjust: 105%;
ascent-override: 90%;
descent-override: 20%;
}
4. CSS contain Property
/* Tell the browser this element's layout is independent */
.card {
contain: layout style; /* Internal changes won't affect outer layout */
}
INP — Interaction to Next Paint
INP replaced FID (First Input Delay) as a Core Web Vital on March 12, 2024. This is a critical interview fact.
INP vs. FID — Key Differences
| FID (deprecated) | INP (current) | |
|---|---|---|
| Measures | Delay before first input handler runs | Full latency from input to next paint |
| Scope | First interaction only | ALL interactions throughout the page lifecycle |
| Includes | Input delay only | Input delay + processing time + presentation delay |
What INP Measures
User clicks button
│
├── Input Delay: time until handler starts (main thread busy?)
│
├── Processing Time: time to execute the handler
│
└── Presentation Delay: time to render and paint the result
│
= Total INP for this interaction
INP reports the worst interaction (approximately the 98th percentile) across the entire page session.
INP Optimization Strategies
1. Break Up Long Tasks
The main thread can only do one thing at a time. Long tasks (> 50ms) block interactions:
// BAD: One long task blocks the main thread
function processLargeList(items) {
items.forEach(item => {
expensiveOperation(item); // blocks for 200ms total
});
}
// GOOD: Yield to the main thread between chunks
async function processLargeList(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 pending interactions
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// BEST: Use scheduler.yield() when available
async function processLargeList(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));
// scheduler.yield() preserves task priority
if ('scheduler' in globalThis && 'yield' in scheduler) {
await scheduler.yield();
} else {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
2. Use requestAnimationFrame for Visual Updates
// BAD: Force layout recalculation mid-frame
button.addEventListener('click', () => {
element.style.width = '200px';
const height = element.offsetHeight; // forces synchronous layout
element.style.height = height + 'px';
});
// GOOD: Batch visual updates in rAF
button.addEventListener('click', () => {
requestAnimationFrame(() => {
element.style.width = '200px';
element.style.height = '200px';
});
});
3. Debounce Input Handlers
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// Debounce search input to avoid processing every keystroke
const searchInput = document.querySelector('#search');
searchInput.addEventListener('input', debounce((e) => {
filterResults(e.target.value);
}, 300));
Measuring Core Web Vitals
Chrome DevTools Performance Tab
- Open DevTools → Performance tab
- Check "Web Vitals" checkbox
- Click Record, interact with the page, then Stop
- The timeline shows LCP, CLS shifts, and interaction events with their durations
Lighthouse
Lighthouse provides a lab-based score. Run it from DevTools → Lighthouse tab, or via CLI:
npx lighthouse https://example.com --output=json --output-path=./report.json
The web-vitals JavaScript Library
import { onLCP, onCLS, onINP } from 'web-vitals';
onLCP(metric => console.log('LCP:', metric.value));
onCLS(metric => console.log('CLS:', metric.value));
onINP(metric => console.log('INP:', metric.value));
Interview tip: Know that Lighthouse measures lab data (simulated conditions), while the Chrome User Experience Report (CrUX) measures field data (real users). Google uses field data for search ranking.
Next, we will explore advanced performance patterns including code splitting, caching, and memoization strategies. :::