CLS measures visual stability — how much content moves around unexpectedly during the page's lifetime. A high CLS means elements jump around while the user is trying to read or interact, causing frustration and mis-clicks.
| Rating | CLS Score |
|---|---|
| Good | ≤ 0.1 |
| Needs Improvement | 0.1 – 0.25 |
| Poor | > 0.25 |
CLS is a unitless score. It's calculated as: impact fraction × distance fraction for each layout shift, summed across the largest "session window" of shifts.
The #1 cause of CLS. When an image loads, it pushes content below it down by the image's height. If the browser doesn't know the dimensions in advance, it allocates 0 height, then shifts everything when the image loads.
#### Before (Causes CLS)
<!-- No dimensions — browser allocates 0 height, then shifts on load -->
<img src="photo.jpg" alt="Team photo">#### After (No CLS)
<!-- Explicit dimensions — browser reserves exact space before image loads -->
<img src="photo.jpg" alt="Team photo" width="800" height="600">
<!-- Or use CSS aspect-ratio for responsive images -->
<img src="photo.jpg" alt="Team photo"
style="width: 100%; height: auto; aspect-ratio: 4/3;">/* Modern CSS approach — works for any element */
.hero-image {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
}
/* For background images */
.hero-banner {
aspect-ratio: 16 / 9;
background-image: url('hero.jpg');
background-size: cover;
}Ads, iframes, and third-party embeds are injected dynamically and often have unknown dimensions.
#### Before (Causes CLS)
<!-- Ad loads asynchronously and pushes content down -->
<div id="ad-slot-1"></div>
<article>Your content here...</article>#### After (No CLS)
<!-- Reserve the maximum expected ad size -->
<div id="ad-slot-1" style="min-height: 250px; background: #f5f5f5;">
<!-- Ad loads here — content below doesn't shift because space is reserved -->
</div>
<article>Your content here...</article>/* For responsive ad slots, use aspect-ratio or container queries */
.ad-slot {
min-height: 250px; /* Standard ad height */
container-type: inline-size;
background: #f5f5f5; /* Placeholder color so it doesn't look broken */
}
/* Adjust for mobile ad sizes */
@container (max-width: 400px) {
.ad-slot {
min-height: 100px; /* Mobile banner ad */
}
}Content inserted above existing content pushes everything down.
#### Before (Causes CLS)
// Cookie banner injected at top of page after load — shifts everything
document.body.insertBefore(cookieBanner, document.body.firstChild);
// Notification bar added to the top after API response
header.prepend(notificationBar);#### After (No CLS)
<!-- Reserve space for the banner in your HTML -->
<div id="cookie-banner-slot" style="min-height: 0; transition: min-height 0.3s;">
<!-- Cookie banner will be injected here -->
</div>
<header>...</header>
<main>...</main>// When showing the banner, animate the height instead of causing a jump
const slot = document.getElementById('cookie-banner-slot');
slot.innerHTML = bannerHTML;
slot.style.minHeight = '60px'; // Smooth transition, no shift
// When dismissing, animate back to 0
function dismissBanner() {
slot.style.minHeight = '0';
setTimeout(() => { slot.innerHTML = ''; }, 300);
}Better approach: Place banners at the BOTTOM of the viewport (fixed position) so they don't shift any content:
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
/* Fixed elements don't cause layout shifts */
}When a custom font loads and replaces the fallback font, text elements can change size, causing shifts.
#### Before (Causes CLS)
/* Default behavior: text is invisible until font loads (FOIT) or
snaps from fallback to custom font with different metrics (FOUT) */
@font-face {
font-family: 'CustomFont';
src: url('custom-font.woff2') format('woff2');
}#### After (Minimal CLS)
/* Use font-display: optional — if the font doesn't load in ~100ms,
the fallback is used for the entire page visit. No shift ever. */
@font-face {
font-family: 'CustomFont';
src: url('custom-font.woff2') format('woff2');
font-display: optional;
}
/* If you must show the custom font, use size-adjust to match metrics */
@font-face {
font-family: 'CustomFont Fallback';
src: local('Arial');
/* These values make Arial render at the same size as CustomFont,
so when the swap happens, text doesn't shift */
ascent-override: 92%;
descent-override: 22%;
line-gap-override: 0%;
size-adjust: 105%;
}
body {
font-family: 'CustomFont', 'CustomFont Fallback', Arial, sans-serif;
}Accordions, tabs, and other components that change height after interaction.
/* Animate height changes smoothly instead of causing abrupt shifts */
.accordion-content {
overflow: hidden;
max-height: 0;
transition: max-height 0.3s ease;
}
.accordion-content.open {
max-height: 500px; /* Or use JavaScript to set exact height */
}// Better: Animate to exact height to avoid wasted space
function openAccordion(panel) {
const content = panel.querySelector('.accordion-content');
// Get the actual height of the content
content.style.maxHeight = content.scrollHeight + 'px';
// After transition, remove maxHeight to allow dynamic content
content.addEventListener('transitionend', function handler() {
content.style.maxHeight = 'none';
content.removeEventListener('transitionend', handler);
});
}SPAs that show a loading skeleton, then replace it with content of different height.
/* Make skeleton elements match the height of real content */
.post-card-skeleton {
height: 320px; /* Must match .post-card height */
border-radius: 8px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Real card — same height as skeleton */
.post-card {
height: 320px; /* Or min-height if content varies */
}When you know content will be injected but don't know the exact size:
/* Use contain: layout for components that shouldn't affect outside layout */
.widget-container {
contain: layout;
/* Content inside can change size without causing shifts outside */
}
/* Or use content-visibility for off-screen content */
.below-the-fold {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Estimated height for layout */
}1. Performance tab: Record a page load → look for "Layout Shift" entries (red bars)
2. Shift each entry: Hover over it to see which element shifted and by how much
3. Rendering tab: Enable "Layout Shift Regions" — highlights shifted areas in blue during live interaction
// Log every layout shift with the element that caused it
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) { // Ignore user-initiated shifts
console.log('Layout shift:', entry.value.toFixed(4));
console.log('Sources:', entry.sources?.map(s =>
s.node?.nodeName + (s.node?.id ? '#' + s.node.id : '')
));
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });Page loads → CLS detected → Which element shifted?
→ Image? → Add width/height attributes
→ Ad/embed? → Reserve space with min-height
→ Text? → Fix font loading (font-display, size-adjust)
→ Dynamic? → Reserve space or animate changes
→ Third-party → Wrap in a fixed-size container
![]()
elements have width and height attributes (or CSS aspect-ratio) elements have width and height attributes elements have explicit dimensionsmin-height reserving spaceposition: fixed (not pushing content)font-display: swap or font-display: optionalsize-adjust / ascent-overridecontain: layout is used on dynamic widget containerstransform and opacity (not width, height, top, left)Part of Frontend Developer Pro
INP measures how quickly your page responds to user interactions — clicks, taps, and keyboard input. It replaced First Input Delay (FID) as a Core Web Vital in March 2024. INP captures the worst-case responsiveness across the entire page lifecycle, not just the first click.
| Rating | INP Time |
|---|---|
| Good | ≤ 200 milliseconds |
| Needs Improvement | 200 – 500 ms |
| Poor | > 500 ms |
INP is the 75th percentile of all interactions during a page visit. One slow interaction can tank your score.
When a user interacts (click, tap, keypress), three phases determine responsiveness:
User clicks → [Input Delay] → [Processing Time] → [Presentation Delay] → Visual update
Input Delay: Time between the click and the event handler starting
Caused by: main thread busy with other tasks
Processing Time: Time spent in the event handler
Caused by: expensive JavaScript in click/keydown handlers
Presentation Delay: Time between handler completing and browser painting the update
Caused by: expensive rendering (large DOM, complex CSS)
Total INP = Input Delay + Processing Time + Presentation Delay
The most common INP problem: the user clicks, but the main thread is busy running JavaScript, so the event handler can't start.
#### Break Up Long Tasks
// BEFORE: One long task that blocks the main thread for 400ms
function processLargeDataset(items) {
// This runs synchronously — blocks all interactions for the duration
for (let i = 0; i < items.length; i++) {
expensiveOperation(items[i]);
}
updateUI();
}
// AFTER: Yield to the browser between chunks
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);
for (const item of chunk) {
expensiveOperation(item);
}
// Yield to the browser — allows pending interactions to be processed
// scheduler.yield() is the modern API; setTimeout(0) is the fallback
if (typeof scheduler !== 'undefined' && scheduler.yield) {
await scheduler.yield();
} else {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
updateUI();
}#### Use requestIdleCallback for Non-Urgent Work
// Don't run analytics tracking or prefetching during user interaction
function trackEvent(data) {
// requestIdleCallback runs when the browser is idle — doesn't block interactions
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
sendAnalytics(data);
}, { timeout: 2000 }); // Maximum wait: 2 seconds
} else {
// Fallback: use setTimeout to defer
setTimeout(() => sendAnalytics(data), 0);
}
}#### Defer Third-Party Scripts
Third-party scripts (analytics, ads, chat widgets) often run expensive tasks that block the main thread.
<!-- Load third-party scripts with defer or after interaction -->
<script src="https://cdn.example.com/analytics.js" defer></script>
<!-- Or load on user interaction (interaction-based loading) -->
<script>
// Only load the chat widget after the first user interaction
let chatLoaded = false;
document.addEventListener('click', function loadChat() {
if (!chatLoaded) {
chatLoaded = true;
const script = document.createElement('script');
script.src = 'https://cdn.example.com/chat-widget.js';
document.body.appendChild(script);
document.removeEventListener('click', loadChat);
}
}, { once: true });
</script>Keep event handlers fast — ideally under 50ms.
#### Move Computation Off the Main Thread
// BEFORE: Expensive computation in click handler
document.querySelector('#search-btn').addEventListener('click', () => {
const results = searchDatabase(query); // 200ms synchronous operation
renderResults(results);
});
// AFTER: Use a Web Worker for the computation
const searchWorker = new Worker('search-worker.js');
document.querySelector('#search-btn').addEventListener('click', () => {
// Show loading state immediately (fast — no computation)
renderLoadingState();
// Offload heavy work to a worker (doesn't block main thread)
searchWorker.postMessage({ query });
});
searchWorker.addEventListener('message', (event) => {
// Worker done — render results
renderResults(event.data.results);
});// search-worker.js — runs on a separate thread
self.addEventListener('message', (event) => {
const { query } = event.data;
const results = searchDatabase(query); // Heavy computation here is fine
self.postMessage({ results });
});#### Debounce Rapid Interactions
// BEFORE: Expensive handler fires on every keystroke
input.addEventListener('input', (e) => {
const results = filterItems(e.target.value); // Runs 10x per second while typing
renderResults(results);
});
// AFTER: Debounce — only process after the user pauses typing
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
input.addEventListener('input', debounce((e) => {
const results = filterItems(e.target.value);
renderResults(results);
}, 200)); // Wait 200ms after last keystroke#### Avoid Forced Synchronous Layout
// BEFORE: Forced layout (reads then writes in a loop — very expensive)
elements.forEach(el => {
// Reading offsetHeight forces layout recalculation EVERY iteration
const height = el.offsetHeight;
el.style.maxHeight = height + 'px';
});
// AFTER: Batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight); // Read all at once
elements.forEach((el, i) => {
el.style.maxHeight = heights[i] + 'px'; // Write all at once
});After your event handler runs, the browser needs to update the visual display.
#### Minimize DOM Size
Small DOM (<800 nodes): Rendering is fast — updates paint in <10ms
Medium DOM (800-1400): Rendering is acceptable — updates paint in 10-50ms
Large DOM (>1400 nodes): Rendering is slow — updates can take 50-200ms+
Google recommends: <1400 total nodes, <32 nesting depth, <60 children per element
#### Use CSS contain to Limit Reflow Scope
/* Tell the browser: changes inside this element don't affect outside layout */
.widget-card {
contain: content;
/* Now when the card's content changes, the browser only recalculates
layout for this element and its children — not the entire page */
}
/* For elements that never change size based on content */
.fixed-size-component {
contain: strict;
width: 300px;
height: 200px;
}#### Use content-visibility: auto for Off-Screen Content
/* Browser skips rendering for off-screen sections entirely */
.content-section {
content-visibility: auto;
contain-intrinsic-size: auto 500px; /* Estimated height */
}
/* This can dramatically reduce rendering work on long pages.
The browser only renders sections when they're near the viewport. */#### Prefer transform and opacity for Animations
/* SLOW: Animating layout properties triggers full reflow + repaint */
.card:hover {
width: 320px; /* Triggers layout */
height: 240px; /* Triggers layout */
margin-top: -10px; /* Triggers layout */
}
/* FAST: transform and opacity are compositor-only (no layout, no paint) */
.card:hover {
transform: scale(1.05) translateY(-10px); /* GPU-accelerated */
opacity: 0.95; /* GPU-accelerated */
}#### React: Use startTransition for Non-Urgent Updates
import { useState, startTransition } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
function handleSearch(e) {
const value = e.target.value;
// Urgent: update the input immediately (responsive typing)
setQuery(value);
// Non-urgent: update search results in the background
startTransition(() => {
setResults(filterItems(value));
});
}
return (
<>
<input value={query} onChange={handleSearch} />
<ResultsList results={results} />
</>
);
}#### React: Virtualize Large Lists
// Don't render 10,000 list items — only render what's visible
import { FixedSizeList } from 'react-window';
function VirtualizedList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>{items[index].name}</div>
);
return (
<FixedSizeList
height={400}
width="100%"
itemCount={items.length}
itemSize={50}
>
{Row}
</FixedSizeList>
);
}// Log every interaction slower than 200ms
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
console.warn('Slow interaction:', {
type: entry.name, // 'click', 'keydown', etc.
duration: entry.duration + 'ms',
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
target: entry.target, // The element that was interacted with
inputDelay: entry.processingStart - entry.startTime,
processingTime: entry.processingEnd - entry.processingStart,
presentationDelay: entry.startTime + entry.duration - entry.processingEnd,
});
}
}
});
observer.observe({ type: 'event', buffered: true, durationThreshold: 16 });1. Performance tab: Record → interact with the page → stop recording
2. Look for "Long Tasks" (yellow bars >50ms) — these block interactions
3. Expand event entries to see input delay vs. processing time vs. rendering
4. Main thread view: See exactly what JavaScript was running during slow interactions
yield() or setTimeout(0)contain is used on independent componentstransform/opacity only (compositor-accelerated)startTransition used for non-urgent state updatesPart of Frontend Developer Pro
Get the full Frontend Performance Guide and unlock everything.
Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.
Access all interactive tools with complete data, all workload profiles, and the full scenario library.
Downloadable source code, configuration files, and working examples from every chapter.
Free updates for life. Every new chapter, tool, and improvement included.