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 |
Memorising the numbers is the easy half. The half candidates miss: a page is assessed at the 75th percentile of page loads, segmented separately for mobile and desktop. "Good LCP" does not mean your average was 2.5s — it means three-quarters of real page loads came in at or under 2.5s. That distinction is why a site can pass in Lighthouse and fail in the field, and saying it unprompted is worth more than reciting the table.
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
The shift happens at the moment of the swap: the fallback font is a different size and shape
from the web font, so text reflows when the real one arrives. The descriptors that fix this —
size-adjust, ascent-override, descent-override — go on a @font-face rule for the
fallback, not on the web font's own rule. Put them on the web font and you change how it
renders, which moves the text you were trying to hold still.
/* The web font: no metric overrides here */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
}
/* A local font, re-declared under its own name, adjusted to CustomFont's metrics.
These percentages belong to this font pair — measure them, do not copy them. */
@font-face {
font-family: 'CustomFont-fallback';
src: local('Arial');
size-adjust: 105%;
ascent-override: 90%;
descent-override: 20%;
}
body {
font-family: 'CustomFont', 'CustomFont-fallback', sans-serif;
}
Do not treat those three percentages as constants. They are the ratio between two specific
fonts, and copying them out of an article gives you a fallback that mismatches in a new
direction. Derive them from the two fonts' own metrics — unitsPerEm, ascender, descender,
and average character width — or generate them with a tool that reads the font files. The value
you want is computed, so the durable form of this advice is the derivation, not the number.
MDN's size-adjust reference
applies it to a local() fallback for exactly this reason.
Interview follow-up: "you added size-adjust — to which rule?" separates someone who has
shipped this from someone who has read about it, and it costs the interviewer one sentence.
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
The three phases inside one interaction
INP measures the whole distance from input to the next painted frame, not just the wait before your handler ran. Each phase has a different fix, which is why 'my handler is fast' is not an answer to a bad INP score.
Two percentiles are involved and candidates routinely blur them:
- Within one page visit, INP reports the longest interaction — with an allowance for outliers. One high interaction is discarded for every 50 interactions, so on a heavily interactive page the reported value approaches the 98th percentile of that page's interactions rather than its literal worst.
- Across your users, the score you are graded on is the 75th percentile of page loads, as with every Core Web Vital.
The first rule is why a single unlucky hitch on a busy page does not sink you. The second is why fixing the interaction that only your slowest 10% of users hit does not move the number.
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. Read Before You Write
The expensive pattern is not "writing styles" — it is reading a layout property after writing one. The write invalidates layout; the read forces the browser to recompute it there and then, synchronously, in the middle of your handler. Do that in a loop and you get layout thrashing.
Same handler, one line moved
javascript1button.addEventListener('click', () => {2 element.style.width = '200px';34 // The write above invalidated layout, so this read5 // has to recompute it synchronously, right now.6 const height = element.offsetHeight;78 element.style.height = height + 'px';9});
1button.addEventListener('click', () => {2 // Reads the layout the browser already has from3 // the last frame. Nothing to recompute.4 const height = element.offsetHeight;56 requestAnimationFrame(() => {7 element.style.width = '200px';8 element.style.height = height + 'px';9 });10});
requestAnimationFrame is doing one job here: it moves the writes into the frame the browser
was about to paint anyway. It is not what removes the forced layout — the ordering is. A
read-after-write inside a rAF callback forces layout exactly as hard as one outside it, which is
the detail an interviewer will push on if you offer "I'd wrap it in rAF" as the whole answer.
Chrome's guidance states the rule directly: batch your reads, do them first, then do the writes
(avoid large, complex layouts and layout thrashing).
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. :::
Sign in to rate