Core Web Vitals: The Definitive Performance Optimization Guide
Core Web Vitals: The Definitive Performance Optimization Guide
Introduction: Why Web Vitals Matter
Core Web Vitals aren't vanity metrics—they directly correlate with business outcomes. Google's research shows:
- LCP improvement of 100ms → 1.3% increase in conversion rate
- CLS improvement of 0.1 → 15% reduction in bounce rate
- INP under 200ms → 22% more page views per session
Beyond SEO ranking factors, these metrics represent real user experience. A user who waits 4 seconds for LCP has already mentally checked out. A page that shifts during interaction destroys trust. Sluggish responses make users feel the app is broken.
This guide provides production-grade optimization strategies for each Core Web Vital, with real code examples and architectural patterns.
┌─────────────────────────────────────────────────────────────────────────────┐
│ CORE WEB VITALS OVERVIEW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ METRIC MEASURES GOOD NEEDS WORK POOR │
│ ───────────────────────────────────────────────────────────────────────── │
│ LCP Loading performance ≤2.5s 2.5s-4s >4s │
│ INP Interactivity ≤200ms 200ms-500ms >500ms │
│ CLS Visual stability ≤0.1 0.1-0.25 >0.25 │
│ │
│ SUPPORTING METRICS (not Core, but critical): │
│ ───────────────────────────────────────────────────────────────────────── │
│ TTFB Server responsiveness ≤800ms 800ms-1.8s >1.8s │
│ FCP First visual feedback ≤1.8s 1.8s-3s >3s │
│ TBT Main thread blocking ≤200ms 200ms-600ms >600ms │
│ │
│ MEASUREMENT: │
│ • Lab: Lighthouse, WebPageTest, Chrome DevTools │
│ • Field: CrUX, RUM (web-vitals library), Analytics │
│ • Target p75 (75th percentile) for Core Web Vitals │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Part 1: Largest Contentful Paint (LCP)
What LCP Actually Measures
LCP marks when the largest content element becomes visible in the viewport. Eligible elements:
<img>elements<image>inside<svg><video>poster images- Elements with
background-image(CSS) - Block-level text elements (
<p>,<h1>, etc.)
┌─────────────────────────────────────────────────────────────────────────────┐
│ LCP TIMELINE BREAKDOWN │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Request ──▶ TTFB ──▶ Resource Load ──▶ Resource Load Delay ──▶ Render │
│ │ │ │ │ │ │
│ │ Server time Download time Blocked by other Paint time │
│ │ resources │
│ │
│ EXAMPLE: Hero image LCP of 3.2s │
│ ┌────────┬────────────┬─────────────────┬────────────┬─────────┐ │
│ │ DNS │ TTFB │ HTML Download │ Image Load │ Render │ │
│ │ 50ms │ 400ms │ 200ms │ 2400ms │ 150ms │ │
│ └────────┴────────────┴─────────────────┴────────────┴─────────┘ │
│ │
│ OPTIMIZATION TARGET: Reduce Image Load (2400ms is the bottleneck) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
LCP Optimization Strategy 1: Resource Prioritization
// Priority hints tell the browser what to load first
interface ResourcePriority {
fetchpriority: 'high' | 'low' | 'auto';
loading: 'eager' | 'lazy';
decoding: 'sync' | 'async' | 'auto';
}
// LCP image component with all priority optimizations
export function LCPImage({
src,
srcSet,
sizes,
alt,
width,
height,
}: LCPImageProps) {
return (
<img
src={src}
srcSet={srcSet}
sizes={sizes}
alt={alt}
width={width}
height={height}
// Priority hints for LCP
fetchpriority="high" // Fetch before other images
loading="eager" // Don't lazy load LCP
decoding="sync" // Decode synchronously (blocks render but faster LCP)
// Prevent CLS
style={{ aspectRatio: `${width} / ${height}` }}
/>
);
}
// Preload LCP image in document head
// Add this via SSR or in your HTML template
function getLCPPreloadTag(imageUrl: string, imageSrcSet?: string): string {
if (imageSrcSet) {
return `<link
rel="preload"
as="image"
href="${imageUrl}"
imagesrcset="${imageSrcSet}"
imagesizes="100vw"
fetchpriority="high"
/>`;
}
return `<link rel="preload" as="image" href="${imageUrl}" fetchpriority="high" />`;
}
LCP Optimization Strategy 2: Server-Side Rendering & Streaming
// Streaming SSR to get LCP content to browser faster
import { renderToPipeableStream } from 'react-dom/server';
export async function handleRequest(req: Request): Promise<Response> {
// Start streaming immediately - don't wait for all data
const { pipe, abort } = renderToPipeableStream(
<Document>
<App url={req.url} />
</Document>,
{
// Stream shell with LCP content immediately
onShellReady() {
// Headers must be set before streaming begins
res.setHeader('Content-Type', 'text/html');
res.setHeader('Transfer-Encoding', 'chunked');
// Begin streaming HTML
pipe(res);
},
// Handle shell errors (critical path failed)
onShellError(error) {
// Fall back to client-side rendering
res.status(500).send(getClientFallbackHTML());
},
// Called when all Suspense boundaries resolve
onAllReady() {
// Useful for crawlers that need complete HTML
},
bootstrapScripts: ['/static/js/main.js'],
}
);
// Abort if request takes too long
setTimeout(() => abort(), 10000);
}
// Document structure optimized for streaming
function Document({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
{/* Critical CSS inlined - no external request */}
<style dangerouslySetInnerHTML={{ __html: criticalCSS }} />
{/* Preload LCP image */}
<link
rel="preload"
as="image"
href="/hero.webp"
imagesrcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
imagesizes="100vw"
/>
{/* Preconnect to critical origins */}
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="dns-prefetch" href="https://analytics.example.com" />
</head>
<body>
{children}
</body>
</html>
);
}
LCP Optimization Strategy 3: Image Optimization Pipeline
// Comprehensive image optimization for LCP
interface ImageOptimizationConfig {
// Format selection
formats: ('avif' | 'webp' | 'jpg')[];
// Responsive sizes
widths: number[];
// Quality settings (format-specific)
quality: {
avif: number; // 50-60 recommended (AVIF compresses better)
webp: number; // 75-85 recommended
jpg: number; // 80-90 recommended
};
// Placeholder strategy
placeholder: 'blur' | 'dominant-color' | 'lqip' | 'none';
}
const defaultConfig: ImageOptimizationConfig = {
formats: ['avif', 'webp', 'jpg'],
widths: [320, 640, 960, 1280, 1920, 2560],
quality: { avif: 55, webp: 80, jpg: 85 },
placeholder: 'blur',
};
// Generate optimized image HTML
function generateOptimizedImage(
src: string,
alt: string,
config: ImageOptimizationConfig = defaultConfig
): string {
const { formats, widths, quality } = config;
// Generate srcset for each format
const sources = formats.slice(0, -1).map(format => {
const srcset = widths
.map(w => `${generateUrl(src, w, format, quality[format])} ${w}w`)
.join(', ');
return `<source
type="image/${format}"
srcset="${srcset}"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>`;
});
// Fallback format (last in array)
const fallbackFormat = formats[formats.length - 1];
const fallbackSrcset = widths
.map(w => `${generateUrl(src, w, fallbackFormat, quality[fallbackFormat])} ${w}w`)
.join(', ');
return `
<picture>
${sources.join('\n')}
<img
src="${generateUrl(src, 960, fallbackFormat, quality[fallbackFormat])}"
srcset="${fallbackSrcset}"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
alt="${alt}"
loading="eager"
decoding="sync"
fetchpriority="high"
/>
</picture>
`;
}
// Image CDN URL generator (adjust for your CDN)
function generateUrl(
src: string,
width: number,
format: string,
quality: number
): string {
// Cloudflare Image Resizing
return `https://example.com/cdn-cgi/image/width=${width},format=${format},quality=${quality}/${src}`;
// Or Imgix
// return `https://example.imgix.net/${src}?w=${width}&fm=${format}&q=${quality}&auto=compress`;
// Or Cloudinary
// return `https://res.cloudinary.com/example/image/upload/w_${width},f_${format},q_${quality}/${src}`;
}
LCP Optimization Strategy 4: Critical CSS Inlining
// Extract and inline critical CSS for above-the-fold content
import { PurgeCSS } from 'purgecss';
import CleanCSS from 'clean-css';
async function generateCriticalCSS(
html: string,
cssFiles: string[]
): Promise<string> {
// Read all CSS files
const fullCSS = cssFiles
.map(file => fs.readFileSync(file, 'utf-8'))
.join('\n');
// Extract only CSS used in HTML
const purged = await new PurgeCSS().purge({
content: [{ raw: html, extension: 'html' }],
css: [{ raw: fullCSS }],
// Keep animations and pseudo-classes
safelist: {
standard: [/^::/],
deep: [/animate/, /transition/],
},
});
// Minify the result
const minified = new CleanCSS({
level: 2,
compatibility: '*',
}).minify(purged[0].css);
return minified.styles;
}
// Inject critical CSS and defer non-critical
function injectCriticalCSS(html: string, criticalCSS: string): string {
const criticalStyle = `<style id="critical-css">${criticalCSS}</style>`;
// Load full CSS asynchronously
const deferredCSS = `
<link rel="preload" href="/styles/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
`;
// Insert into head
return html.replace(
'</head>',
`${criticalStyle}${deferredCSS}</head>`
);
}
LCP Optimization Strategy 5: Early Hints (103)
// Send 103 Early Hints before main response
// This lets browser start fetching resources while server processes request
// Express middleware for Early Hints
function earlyHintsMiddleware(req: Request, res: Response, next: NextFunction) {
// Only for HTML requests
if (!req.accepts('html')) {
return next();
}
// Send 103 Early Hints
res.writeEarlyHints({
link: [
// Preload LCP image
'</images/hero.webp>; rel=preload; as=image; fetchpriority=high',
// Preload critical CSS
'</styles/critical.css>; rel=preload; as=style',
// Preload main JS bundle
'</scripts/main.js>; rel=preload; as=script',
// Preconnect to CDN
'<https://cdn.example.com>; rel=preconnect',
],
});
next();
}
// Cloudflare Workers implementation
export default {
async fetch(request: Request): Promise<Response> {
// Create early hints response
const earlyHints = new Response(null, {
status: 103,
headers: {
'Link': [
'</hero.webp>; rel=preload; as=image',
'</main.css>; rel=preload; as=style',
].join(', '),
},
});
// Send early hints (non-blocking)
// Note: This is pseudo-code - actual implementation depends on runtime
sendEarlyHints(earlyHints);
// Continue processing main request
const response = await handleRequest(request);
return response;
},
};
Part 2: Interaction to Next Paint (INP)
Understanding INP
INP measures responsiveness - the time from user interaction (click, tap, keypress) to the next paint. Unlike FID (First Input Delay), INP considers ALL interactions throughout the page lifecycle and reports the worst (with outliers excluded).
┌─────────────────────────────────────────────────────────────────────────────┐
│ INP INTERACTION BREAKDOWN │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ User clicks ──▶ Input Delay ──▶ Processing ──▶ Presentation Delay ──▶ Paint│
│ │ │ │ │ │ │
│ │ Main thread Event handler Render/paint │ │
│ │ blocked? execution time scheduled │ │
│ │
│ EXAMPLE: Button click with INP of 450ms │
│ ┌────────────────┬─────────────────────┬───────────────┬─────────┐ │
│ │ Input Delay │ Processing │ Present Delay │ Paint │ │
│ │ 150ms │ 200ms │ 80ms │ 20ms │ │
│ │ (Long task │ (Expensive state │ (React │ │ │
│ │ blocking) │ update) │ re-render) │ │ │
│ └────────────────┴─────────────────────┴───────────────┴─────────┘ │
│ │
│ TARGET: Total < 200ms │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
INP Optimization Strategy 1: Yield to Main Thread
// The main thread is single-threaded - long tasks block interactions
// Solution: Break up long tasks and yield to allow interaction processing
// Yield function using scheduler.yield() with fallback
async function yieldToMain(): Promise<void> {
// scheduler.yield() is the preferred API (Chrome 115+)
if ('scheduler' in globalThis && 'yield' in (globalThis as any).scheduler) {
return (globalThis as any).scheduler.yield();
}
// Fallback: setTimeout(0) moves to back of task queue
return new Promise(resolve => setTimeout(resolve, 0));
}
// Process large array without blocking main thread
async function processLargeArray<T, R>(
items: T[],
processor: (item: T) => R,
chunkSize: number = 100
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
// Process chunk synchronously
for (const item of chunk) {
results.push(processor(item));
}
// Yield after each chunk to allow interactions
if (i + chunkSize < items.length) {
await yieldToMain();
}
}
return results;
}
// Usage in React
function ProductList({ products }: { products: Product[] }) {
const [processedProducts, setProcessedProducts] = useState<ProcessedProduct[]>([]);
const [isProcessing, setIsProcessing] = useState(true);
useEffect(() => {
async function process() {
setIsProcessing(true);
const results = await processLargeArray(
products,
(product) => ({
...product,
formattedPrice: formatPrice(product.price),
discountedPrice: calculateDiscount(product),
availability: checkAvailability(product),
}),
50 // Process 50 items, then yield
);
setProcessedProducts(results);
setIsProcessing(false);
}
process();
}, [products]);
if (isProcessing) {
return <ProductListSkeleton count={products.length} />;
}
return (
<div className="product-grid">
{processedProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
INP Optimization Strategy 2: React Concurrent Features
import {
useTransition,
useDeferredValue,
startTransition,
Suspense,
} from 'react';
// useTransition: Mark updates as non-urgent
function SearchWithTransition() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (value: string) => {
// Urgent: Update input immediately (good INP)
setQuery(value);
// Non-urgent: Update results can be interrupted
startTransition(() => {
const filtered = filterProducts(value); // Expensive operation
setResults(filtered);
});
};
return (
<div>
<input
type="search"
value={query}
onChange={(e) => handleSearch(e.target.value)}
// Input responds instantly - good INP
/>
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<SearchResults results={results} />
</div>
</div>
);
}
// useDeferredValue: Defer expensive re-renders
function FilteredList({ items, filter }: { items: Item[]; filter: string }) {
// Deferred value may "lag behind" the actual filter
// This lets React interrupt the expensive render
const deferredFilter = useDeferredValue(filter);
// Show stale indicator when values differ
const isStale = filter !== deferredFilter;
// Expensive filtering uses deferred value
const filteredItems = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(deferredFilter.toLowerCase())
);
}, [items, deferredFilter]);
return (
<div style={{ opacity: isStale ? 0.8 : 1 }}>
{filteredItems.map(item => (
<ListItem key={item.id} item={item} />
))}
</div>
);
}
// Combining both for optimal INP
function OptimizedSearch({ allProducts }: { allProducts: Product[] }) {
const [searchTerm, setSearchTerm] = useState('');
const [category, setCategory] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<SortOption>('relevance');
// Defer the search term for expensive filtering
const deferredSearchTerm = useDeferredValue(searchTerm);
// Use transition for sort changes (less urgent than typing)
const [isSorting, startSortTransition] = useTransition();
const handleSortChange = (newSort: SortOption) => {
startSortTransition(() => {
setSortBy(newSort);
});
};
// Memoize expensive computation
const filteredAndSorted = useMemo(() => {
let result = allProducts;
// Filter by search
if (deferredSearchTerm) {
result = result.filter(p =>
p.name.toLowerCase().includes(deferredSearchTerm.toLowerCase())
);
}
// Filter by category
if (category) {
result = result.filter(p => p.category === category);
}
// Sort
result = sortProducts(result, sortBy);
return result;
}, [allProducts, deferredSearchTerm, category, sortBy]);
return (
<div>
<SearchInput
value={searchTerm}
onChange={setSearchTerm} // Instant feedback
/>
<CategoryFilter
value={category}
onChange={setCategory}
/>
<SortDropdown
value={sortBy}
onChange={handleSortChange}
isPending={isSorting}
/>
<ProductGrid products={filteredAndSorted} />
</div>
);
}
INP Optimization Strategy 3: Event Handler Optimization
// Anti-pattern: Expensive work in event handler
function BadButton() {
const handleClick = () => {
// ❌ All this runs before next paint (blocks INP)
const data = expensiveCalculation();
updateDatabase(data);
logAnalytics('click', data);
updateUI(data);
};
return <button onClick={handleClick}>Click me</button>;
}
// Optimized: Separate visual feedback from expensive work
function GoodButton() {
const [isProcessing, setIsProcessing] = useState(false);
const handleClick = async () => {
// ✅ Immediate visual feedback (fast INP)
setIsProcessing(true);
// Yield to allow paint
await yieldToMain();
// Now do expensive work (after paint)
const data = expensiveCalculation();
// Non-blocking: fire and forget
queueMicrotask(() => logAnalytics('click', data));
// Update state (will trigger another render)
await updateDatabase(data);
setIsProcessing(false);
};
return (
<button onClick={handleClick} disabled={isProcessing}>
{isProcessing ? 'Processing...' : 'Click me'}
</button>
);
}
// Advanced: Priority-based task scheduler
type TaskPriority = 'user-blocking' | 'user-visible' | 'background';
class TaskScheduler {
private queues: Map<TaskPriority, Array<() => void>> = new Map([
['user-blocking', []],
['user-visible', []],
['background', []],
]);
schedule(task: () => void, priority: TaskPriority) {
this.queues.get(priority)!.push(task);
this.flush();
}
private async flush() {
// User-blocking: immediate (but after current paint)
const userBlocking = this.queues.get('user-blocking')!;
while (userBlocking.length > 0) {
const task = userBlocking.shift()!;
task();
await yieldToMain(); // Allow interactions between tasks
}
// User-visible: requestAnimationFrame
const userVisible = this.queues.get('user-visible')!;
if (userVisible.length > 0) {
requestAnimationFrame(() => {
const task = userVisible.shift();
if (task) task();
if (userVisible.length > 0) this.flush();
});
}
// Background: requestIdleCallback
const background = this.queues.get('background')!;
if (background.length > 0) {
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && background.length > 0) {
const task = background.shift()!;
task();
}
if (background.length > 0) this.flush();
});
}
}
}
const scheduler = new TaskScheduler();
// Usage
function OptimizedForm() {
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
// User-blocking: Show loading state
scheduler.schedule(() => {
setIsSubmitting(true);
}, 'user-blocking');
// User-visible: Validate and prepare data
scheduler.schedule(() => {
const isValid = validateForm();
if (!isValid) {
setIsSubmitting(false);
setErrors(getValidationErrors());
}
}, 'user-visible');
// Background: Analytics
scheduler.schedule(() => {
trackFormSubmission(formData);
}, 'background');
};
return <form onSubmit={handleSubmit}>...</form>;
}
INP Optimization Strategy 4: Web Workers for Heavy Computation
// Move expensive calculations off main thread entirely
// worker.ts
self.onmessage = (event: MessageEvent<WorkerInput>) => {
const { type, payload } = event.data;
switch (type) {
case 'FILTER_PRODUCTS': {
const { products, filters } = payload;
const filtered = filterProducts(products, filters);
self.postMessage({ type: 'FILTER_RESULT', payload: filtered });
break;
}
case 'CALCULATE_STATISTICS': {
const { data } = payload;
const stats = calculateComplexStatistics(data);
self.postMessage({ type: 'STATISTICS_RESULT', payload: stats });
break;
}
case 'PARSE_CSV': {
const { csvText } = payload;
const parsed = parseCSV(csvText);
self.postMessage({ type: 'CSV_RESULT', payload: parsed });
break;
}
}
};
// useWorker.ts - React hook for worker communication
function useWorker<TInput, TOutput>(workerPath: string) {
const workerRef = useRef<Worker | null>(null);
const callbacksRef = useRef<Map<string, (result: TOutput) => void>>(new Map());
useEffect(() => {
workerRef.current = new Worker(workerPath);
workerRef.current.onmessage = (event: MessageEvent) => {
const { type, payload, requestId } = event.data;
const callback = callbacksRef.current.get(requestId);
if (callback) {
callback(payload);
callbacksRef.current.delete(requestId);
}
};
return () => {
workerRef.current?.terminate();
};
}, [workerPath]);
const postMessage = useCallback((
type: string,
payload: TInput
): Promise<TOutput> => {
return new Promise((resolve) => {
const requestId = crypto.randomUUID();
callbacksRef.current.set(requestId, resolve);
workerRef.current?.postMessage({ type, payload, requestId });
});
}, []);
return { postMessage };
}
// Usage in component
function DataTable({ rawData }: { rawData: RawData[] }) {
const [processedData, setProcessedData] = useState<ProcessedData[]>([]);
const { postMessage } = useWorker<WorkerInput, WorkerOutput>('/workers/data.js');
useEffect(() => {
// Heavy processing happens in worker - main thread stays responsive
postMessage('PROCESS_DATA', { data: rawData })
.then(result => setProcessedData(result));
}, [rawData, postMessage]);
// Component remains responsive during processing
return <Table data={processedData} />;
}
INP Optimization Strategy 5: Virtualization
// Render only visible items to reduce DOM operations
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated row height
overscan: 5, // Render 5 extra items above/below viewport
});
return (
<div
ref={parentRef}
style={{ height: '400px', overflow: 'auto' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ListItem item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
// Virtualized grid for product listings
function VirtualizedProductGrid({ products }: { products: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState(4);
// Responsive columns
useEffect(() => {
const updateColumns = () => {
const width = parentRef.current?.clientWidth ?? 1200;
if (width < 640) setColumns(1);
else if (width < 1024) setColumns(2);
else if (width < 1440) setColumns(3);
else setColumns(4);
};
updateColumns();
window.addEventListener('resize', updateColumns);
return () => window.removeEventListener('resize', updateColumns);
}, []);
const rowCount = Math.ceil(products.length / columns);
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => 350, // Card height + gap
overscan: 2,
});
return (
<div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * columns;
const rowProducts = products.slice(startIndex, startIndex + columns);
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: '16px',
}}
>
{rowProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
})}
</div>
</div>
);
}
Part 3: Cumulative Layout Shift (CLS)
Understanding CLS
CLS measures visual stability - how much the page content shifts unexpectedly. A layout shift occurs when a visible element changes its position from one frame to the next.
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLS CALCULATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Layout Shift Score = Impact Fraction × Distance Fraction │
│ │
│ BEFORE SHIFT AFTER SHIFT │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Header │ │ Header │ │
│ ├──────────────┤ ├──────────────┤ │
│ │ │ │ AD LOADS │ ← New element inserted │
│ │ Content │ ├──────────────┤ │
│ │ │ │ │ │
│ │ │ │ Content │ ← Shifted down │
│ │ │ │ │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ Impact Fraction: 75% of viewport affected │
│ Distance Fraction: Content moved 25% of viewport height │
│ Layout Shift Score: 0.75 × 0.25 = 0.1875 │
│ │
│ CLS = Sum of all unexpected layout shift scores │
│ (Shifts within 500ms of user input are excluded) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
CLS Optimization Strategy 1: Reserve Space for Dynamic Content
// Always reserve space for content that loads asynchronously
// Image with explicit dimensions
function StableImage({ src, alt, width, height }: ImageProps) {
return (
<img
src={src}
alt={alt}
width={width}
height={height}
// CSS ensures aspect ratio is maintained during load
style={{
aspectRatio: `${width} / ${height}`,
width: '100%',
height: 'auto',
backgroundColor: '#f0f0f0', // Placeholder color
}}
/>
);
}
// Ad container with reserved space
function AdContainer({ slot, width, height }: AdContainerProps) {
const [isLoaded, setIsLoaded] = useState(false);
return (
<div
className="ad-container"
style={{
// Always reserve exact space
width: `${width}px`,
height: `${height}px`,
minWidth: `${width}px`,
minHeight: `${height}px`,
// Contain prevents layout shifts from affecting other elements
contain: 'strict',
// Background shows while loading
backgroundColor: isLoaded ? 'transparent' : '#f5f5f5',
}}
>
<Ad
slot={slot}
onLoad={() => setIsLoaded(true)}
style={{ width: '100%', height: '100%' }}
/>
</div>
);
}
// Dynamic content with skeleton placeholder
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useUser(userId);
// Skeleton exactly matches final layout
if (isLoading) {
return (
<div className="user-profile" style={{ height: '200px' }}>
<div className="avatar-skeleton" style={{ width: 80, height: 80 }} />
<div className="name-skeleton" style={{ width: 150, height: 24 }} />
<div className="bio-skeleton" style={{ width: '100%', height: 60 }} />
</div>
);
}
return (
<div className="user-profile" style={{ height: '200px' }}>
<img className="avatar" src={user.avatar} width={80} height={80} />
<h2 className="name">{user.name}</h2>
<p className="bio">{user.bio}</p>
</div>
);
}
CLS Optimization Strategy 2: CSS contain and content-visibility
/* contain: Isolate element from affecting/being affected by outside layout */
/* strict: Full containment - size, layout, style, paint */
.ad-slot {
contain: strict;
width: 300px;
height: 250px;
}
/* layout: Prevent internal changes from affecting external layout */
.card {
contain: layout;
}
/* content: Contain everything except size */
.sidebar {
contain: content;
}
/* content-visibility: Skip rendering of off-screen content */
.below-fold-section {
content-visibility: auto;
/* Must provide intrinsic size to prevent CLS when content renders */
contain-intrinsic-size: auto 500px;
}
/* Example: Long list with content-visibility */
.list-item {
content-visibility: auto;
contain-intrinsic-size: 0 100px; /* width: 0 (auto), height: 100px */
}
/* Prevent text CLS during font loading */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: optional; /* Don't show fallback, wait for font or skip */
}
/* Or use swap with adjusted metrics */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
/* Adjust fallback to match custom font metrics */
size-adjust: 105%;
ascent-override: 90%;
descent-override: 20%;
line-gap-override: 0%;
}
CLS Optimization Strategy 3: Font Loading Optimization
// Font loading strategy to prevent CLS
// 1. Preload critical fonts
// In HTML <head>:
// <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
// 2. Use font-display strategically
const fontFaceCSS = `
/* Critical text: optional (no swap flash) */
@font-face {
font-family: 'HeadingFont';
src: url('/fonts/heading.woff2') format('woff2');
font-display: optional;
}
/* Body text: swap with fallback adjustment */
@font-face {
font-family: 'BodyFont';
src: url('/fonts/body.woff2') format('woff2');
font-display: swap;
size-adjust: 100.5%;
ascent-override: 95%;
descent-override: 22%;
line-gap-override: 0%;
}
`;
// 3. JavaScript-based font loading with FOUT control
async function loadFontsWithoutCLS() {
// Check if fonts are cached
if (document.fonts.check('16px BodyFont')) {
document.documentElement.classList.add('fonts-loaded');
return;
}
try {
// Load fonts in parallel
await Promise.all([
document.fonts.load('700 1em HeadingFont'),
document.fonts.load('400 1em BodyFont'),
]);
// Apply fonts after load (single reflow)
document.documentElement.classList.add('fonts-loaded');
} catch (error) {
// Fallback: use system fonts
document.documentElement.classList.add('fonts-failed');
}
}
// 4. CSS for controlled font application
const fontCSS = `
/* Default: system fonts (no CLS) */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* After fonts load: apply custom fonts */
.fonts-loaded body {
font-family: 'BodyFont', -apple-system, BlinkMacSystemFont, sans-serif;
}
.fonts-loaded h1,
.fonts-loaded h2,
.fonts-loaded h3 {
font-family: 'HeadingFont', Georgia, serif;
}
`;
CLS Optimization Strategy 4: Handling Dynamic Insertions
// When content must be inserted dynamically, do it without shift
// Anti-pattern: Insert at top pushes everything down
function BadNotification() {
return (
<div className="notifications">
{/* ❌ New notifications push content down */}
{notifications.map(n => <Notification key={n.id} {...n} />)}
<MainContent />
</div>
);
}
// Pattern 1: Insert below viewport or at bottom
function GoodNotificationBottom() {
return (
<div className="notifications">
<MainContent />
{/* ✅ New notifications appear at bottom - no shift to existing content */}
{notifications.map(n => <Notification key={n.id} {...n} />)}
</div>
);
}
// Pattern 2: Fixed/absolute positioning
function GoodNotificationFixed() {
return (
<>
<MainContent />
{/* ✅ Fixed position doesn't affect layout */}
<div className="notification-container" style={{ position: 'fixed', bottom: 20, right: 20 }}>
{notifications.map(n => <Notification key={n.id} {...n} />)}
</div>
</>
);
}
// Pattern 3: Transform animation instead of layout change
function GoodNotificationTransform() {
const [isVisible, setIsVisible] = useState(false);
return (
<div
className="notification"
style={{
// Reserve space always
height: '60px',
// Use transform for animation (doesn't cause layout shift)
transform: isVisible ? 'translateY(0)' : 'translateY(-100%)',
opacity: isVisible ? 1 : 0,
transition: 'transform 0.3s, opacity 0.3s',
}}
>
{notification.message}
</div>
);
}
// Pattern 4: User-initiated changes (excluded from CLS)
function ExpandableSection({ title, children }: ExpandableSectionProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Clicks within 500ms are excluded from CLS calculation
// So expand/collapse on user action is fine
return (
<div className="expandable">
<button onClick={() => setIsExpanded(!isExpanded)}>
{title}
</button>
{isExpanded && (
<div className="content">
{children}
</div>
)}
</div>
);
}
CLS Optimization Strategy 5: Handle Late-Loading Content
// For content that must load late, use smart insertion strategies
// Intersection Observer to load content only when space is visible
function LazySection({ children, minHeight }: LazySectionProps) {
const [shouldLoad, setShouldLoad] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShouldLoad(true);
observer.disconnect();
}
},
{ rootMargin: '100px' } // Load slightly before visible
);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
style={{
minHeight: isLoaded ? 'auto' : minHeight,
contain: 'layout',
}}
>
{shouldLoad ? (
React.cloneElement(children, {
onLoad: () => setIsLoaded(true),
})
) : (
<Skeleton height={minHeight} />
)}
</div>
);
}
// Smooth height transition for content that changes size
function AnimatedHeight({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | 'auto'>('auto');
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver(([entry]) => {
// Animate height change instead of instant shift
setHeight(entry.contentRect.height);
});
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return (
<div
style={{
height,
overflow: 'hidden',
transition: 'height 0.3s ease-out',
}}
>
<div ref={ref}>{children}</div>
</div>
);
}
Part 4: Supporting Metrics
Time to First Byte (TTFB)
// TTFB optimization at the edge
// 1. Edge caching with stale-while-revalidate
export const config = {
runtime: 'edge',
};
export default async function handler(request: Request) {
const cacheKey = new URL(request.url).pathname;
const cache = caches.default;
// Check cache first
let response = await cache.match(cacheKey);
if (response) {
// Return cached response immediately (fast TTFB)
// Revalidate in background
revalidateInBackground(request, cache, cacheKey);
return response;
}
// Generate fresh response
response = await generateResponse(request);
// Cache for next request
const cacheResponse = new Response(response.body, response);
cacheResponse.headers.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=600');
await cache.put(cacheKey, cacheResponse.clone());
return response;
}
async function revalidateInBackground(
request: Request,
cache: Cache,
cacheKey: string
) {
// Don't await - let it run in background
generateResponse(request).then(async (freshResponse) => {
await cache.put(cacheKey, freshResponse);
});
}
// 2. Database query optimization
async function getPageData(slug: string) {
// Use connection pooling
const client = await pool.connect();
try {
// Single query with joins instead of N+1
const result = await client.query(`
SELECT
p.*,
json_agg(DISTINCT c.*) as comments,
json_agg(DISTINCT t.*) as tags
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
LEFT JOIN post_tags pt ON pt.post_id = p.id
LEFT JOIN tags t ON t.id = pt.tag_id
WHERE p.slug = $1
GROUP BY p.id
`, [slug]);
return result.rows[0];
} finally {
client.release();
}
}
// 3. Compression
// In your server config (Express example)
import compression from 'compression';
app.use(compression({
level: 6, // Balance between compression ratio and CPU
threshold: 1024, // Only compress responses > 1KB
filter: (req, res) => {
// Compress HTML, CSS, JS, JSON
const contentType = res.getHeader('Content-Type');
return /text|javascript|json/.test(contentType as string);
},
}));
First Contentful Paint (FCP)
// FCP optimization: Get ANY content painted as fast as possible
// 1. Inline critical CSS (already covered in LCP section)
// 2. Eliminate render-blocking resources
// In HTML head - defer non-critical scripts
<script src="/analytics.js" defer></script>
<script src="/chat-widget.js" async></script>
// 3. Optimize critical rendering path
function OptimizedDocument() {
return (
<html>
<head>
{/* Critical CSS inline */}
<style dangerouslySetInnerHTML={{ __html: criticalCSS }} />
{/* Preload critical resources */}
<link rel="preload" href="/fonts/main.woff2" as="font" crossOrigin="" />
{/* DNS prefetch for third parties */}
<link rel="dns-prefetch" href="https://cdn.example.com" />
</head>
<body>
{/* Above-fold content renders first */}
<Header />
<HeroSection />
{/* Below-fold deferred */}
<Suspense fallback={null}>
<BelowFoldContent />
</Suspense>
{/* Non-critical JS at end of body */}
<script src="/main.js" defer></script>
</body>
</html>
);
}
// 4. Server-side render above-fold content
// Next.js example with streaming
export default function Page() {
return (
<>
{/* SSR: Renders immediately */}
<Header />
<Hero />
{/* Streams in after initial shell */}
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
</>
);
}
Part 5: Measurement & Monitoring
Real User Monitoring (RUM)
// Comprehensive RUM implementation
import { onLCP, onINP, onCLS, onFCP, onTTFB, Metric } from 'web-vitals';
interface PerformanceData {
url: string;
metrics: Record<string, MetricData>;
navigation: NavigationTiming;
resources: ResourceTiming[];
device: DeviceInfo;
connection: ConnectionInfo;
}
interface MetricData {
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
attribution: Record<string, unknown>;
}
class PerformanceMonitor {
private data: Partial<PerformanceData> = {};
private reported = false;
constructor() {
this.initializeMetrics();
this.collectDeviceInfo();
this.collectNavigationTiming();
// Report on page unload or after all metrics collected
this.setupReporting();
}
private initializeMetrics() {
const handleMetric = (metric: Metric) => {
this.data.metrics = this.data.metrics || {};
this.data.metrics[metric.name] = {
value: metric.value,
rating: metric.rating,
attribution: (metric as any).attribution || {},
};
// Report INP immediately when available (it's final)
if (metric.name === 'INP') {
this.report();
}
};
onLCP(handleMetric);
onINP(handleMetric);
onCLS(handleMetric);
onFCP(handleMetric);
onTTFB(handleMetric);
}
private collectDeviceInfo() {
this.data.device = {
screenWidth: window.screen.width,
screenHeight: window.screen.height,
devicePixelRatio: window.devicePixelRatio,
hardwareConcurrency: navigator.hardwareConcurrency,
deviceMemory: (navigator as any).deviceMemory,
userAgent: navigator.userAgent,
};
if ('connection' in navigator) {
const conn = (navigator as any).connection;
this.data.connection = {
effectiveType: conn.effectiveType,
downlink: conn.downlink,
rtt: conn.rtt,
saveData: conn.saveData,
};
}
}
private collectNavigationTiming() {
// Wait for load event
if (document.readyState === 'complete') {
this.processNavigationTiming();
} else {
window.addEventListener('load', () => {
// Delay to ensure all timing data is available
setTimeout(() => this.processNavigationTiming(), 0);
});
}
}
private processNavigationTiming() {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
if (nav) {
this.data.navigation = {
dns: nav.domainLookupEnd - nav.domainLookupStart,
tcp: nav.connectEnd - nav.connectStart,
ssl: nav.secureConnectionStart > 0
? nav.connectEnd - nav.secureConnectionStart
: 0,
ttfb: nav.responseStart - nav.requestStart,
download: nav.responseEnd - nav.responseStart,
domParsing: nav.domInteractive - nav.responseEnd,
domContentLoaded: nav.domContentLoadedEventEnd - nav.domContentLoadedEventStart,
load: nav.loadEventEnd - nav.loadEventStart,
total: nav.loadEventEnd - nav.fetchStart,
};
}
// Collect resource timing
this.data.resources = performance
.getEntriesByType('resource')
.map((r: PerformanceResourceTiming) => ({
name: r.name,
type: r.initiatorType,
duration: r.duration,
size: r.transferSize,
protocol: r.nextHopProtocol,
}))
.filter(r => r.duration > 100); // Only slow resources
}
private setupReporting() {
// Report on visibility change (tab switch/close)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.report();
}
});
// Report on page unload
window.addEventListener('pagehide', () => this.report());
// Fallback: report after 30 seconds
setTimeout(() => this.report(), 30000);
}
private report() {
if (this.reported) return;
this.reported = true;
this.data.url = window.location.href;
// Use sendBeacon for reliable delivery
const success = navigator.sendBeacon(
'/api/analytics/performance',
JSON.stringify(this.data)
);
// Fallback to fetch if sendBeacon fails
if (!success) {
fetch('/api/analytics/performance', {
method: 'POST',
body: JSON.stringify(this.data),
keepalive: true,
}).catch(() => {});
}
}
}
// Initialize on page load
if (typeof window !== 'undefined') {
new PerformanceMonitor();
}
Performance Budgets & Alerting
// Define performance budgets
interface PerformanceBudget {
metric: string;
budget: number;
percentile: number;
}
const budgets: PerformanceBudget[] = [
{ metric: 'LCP', budget: 2500, percentile: 75 },
{ metric: 'INP', budget: 200, percentile: 75 },
{ metric: 'CLS', budget: 0.1, percentile: 75 },
{ metric: 'TTFB', budget: 800, percentile: 75 },
{ metric: 'FCP', budget: 1800, percentile: 75 },
];
// Server-side budget checking
async function checkBudgets(timeRange: string = '24h'): Promise<BudgetViolation[]> {
const violations: BudgetViolation[] = [];
for (const budget of budgets) {
// Query your analytics database
const value = await getPercentileValue(
budget.metric,
budget.percentile,
timeRange
);
if (value > budget.budget) {
violations.push({
metric: budget.metric,
budget: budget.budget,
actual: value,
percentile: budget.percentile,
overage: ((value - budget.budget) / budget.budget) * 100,
});
}
}
return violations;
}
// Alert on violations
async function alertOnViolations() {
const violations = await checkBudgets();
if (violations.length > 0) {
// Send to Slack/PagerDuty/etc.
await sendAlert({
title: 'Performance Budget Violations',
severity: violations.some(v => v.overage > 50) ? 'critical' : 'warning',
violations,
});
}
}
// Run budget checks periodically
setInterval(alertOnViolations, 60 * 60 * 1000); // Every hour
Part 6: Framework-Specific Optimizations
Next.js
// next.config.js optimizations
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable React strict mode for better debugging
reactStrictMode: true,
// Image optimization
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 60 * 60 * 24 * 365, // 1 year
},
// Compiler optimizations
compiler: {
// Remove console.logs in production
removeConsole: process.env.NODE_ENV === 'production',
},
// Experimental features for performance
experimental: {
// Optimize package imports
optimizePackageImports: ['lodash', '@mui/material', '@mui/icons-material'],
},
// Headers for caching
async headers() {
return [
{
source: '/:all*(svg|jpg|png|webp|avif)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
source: '/_next/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
];
},
};
// Page with optimized loading
// app/products/page.tsx
import { Suspense } from 'react';
import { ProductGrid, ProductGridSkeleton } from '@/components/ProductGrid';
// Generate static params for static generation
export async function generateStaticParams() {
const categories = await getCategories();
return categories.map(c => ({ category: c.slug }));
}
// Revalidate every hour
export const revalidate = 3600;
export default async function ProductsPage() {
return (
<>
{/* SSR: Immediate render */}
<Header />
<CategoryNav />
{/* Streaming: Shows skeleton, then content */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
</>
);
}
// Server Component for data fetching
async function ProductGrid() {
const products = await getProducts();
return (
<div className="grid grid-cols-4 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
React (Vite/CRA)
// vite.config.ts optimizations
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
// Analyze bundle
visualizer({ open: true, gzipSize: true }),
],
build: {
// Chunk splitting
rollupOptions: {
output: {
manualChunks: {
// Vendor chunks
'react-vendor': ['react', 'react-dom'],
'router': ['react-router-dom'],
'state': ['zustand', '@tanstack/react-query'],
// Feature chunks
'charts': ['recharts', 'd3'],
'forms': ['react-hook-form', 'zod'],
},
},
},
// Target modern browsers
target: 'es2020',
// Minification
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
},
// Optimize deps
optimizeDeps: {
include: ['react', 'react-dom', 'react-router-dom'],
},
});
// Lazy loading routes
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
const Checkout = lazy(() => import('./pages/Checkout'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/products/:id" element={<ProductDetail />} />
<Route path="/checkout" element={<Checkout />} />
</Routes>
</Suspense>
);
}
// Prefetch on hover
function NavLink({ to, children }: NavLinkProps) {
const prefetch = () => {
// Prefetch route module
const routeMap: Record<string, () => Promise<unknown>> = {
'/': () => import('./pages/Home'),
'/products': () => import('./pages/Products'),
'/checkout': () => import('./pages/Checkout'),
};
routeMap[to]?.();
};
return (
<Link to={to} onMouseEnter={prefetch}>
{children}
</Link>
);
}
Part 7: Advanced Optimization Techniques
Speculation Rules API (Prefetching Next Navigation)
// Modern prefetching with Speculation Rules
function addSpeculationRules() {
// Check browser support
if (!HTMLScriptElement.supports?.('speculationrules')) {
return;
}
const rules = {
prefetch: [
{
source: 'document',
where: {
// Prefetch internal links on hover
and: [
{ href_matches: '/*' },
{ not: { href_matches: '/api/*' } },
{ not: { href_matches: '*.pdf' } },
],
},
eagerness: 'moderate', // 'immediate' | 'eager' | 'moderate' | 'conservative'
},
],
prerender: [
{
source: 'list',
// Prerender likely next pages
urls: ['/products', '/about', '/contact'],
},
],
};
const script = document.createElement('script');
script.type = 'speculationrules';
script.textContent = JSON.stringify(rules);
document.head.appendChild(script);
}
// React component for speculation rules
function SpeculationRules({ prefetchUrls, prerenderUrls }: SpeculationRulesProps) {
const rules = {
prefetch: prefetchUrls?.length ? [
{ source: 'list', urls: prefetchUrls },
] : undefined,
prerender: prerenderUrls?.length ? [
{ source: 'list', urls: prerenderUrls },
] : undefined,
};
return (
<script
type="speculationrules"
dangerouslySetInnerHTML={{ __html: JSON.stringify(rules) }}
/>
);
}
View Transitions API
// Smooth page transitions without CLS
async function navigateWithTransition(url: string) {
// Fallback for unsupported browsers
if (!document.startViewTransition) {
window.location.href = url;
return;
}
// Start view transition
const transition = document.startViewTransition(async () => {
// Fetch new page content
const response = await fetch(url);
const html = await response.text();
// Parse and update DOM
const parser = new DOMParser();
const newDoc = parser.parseFromString(html, 'text/html');
// Update main content
document.querySelector('main')!.innerHTML =
newDoc.querySelector('main')!.innerHTML;
// Update title
document.title = newDoc.title;
// Update URL
history.pushState({}, '', url);
});
// Wait for transition to complete
await transition.finished;
}
// CSS for view transitions
const viewTransitionCSS = `
/* Default crossfade */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}
/* Hero image morph */
.hero-image {
view-transition-name: hero;
}
::view-transition-old(hero),
::view-transition-new(hero) {
animation-duration: 0.4s;
animation-timing-function: ease-out;
}
/* Slide transition for pages */
::view-transition-old(page) {
animation: slide-out 0.3s ease-out;
}
::view-transition-new(page) {
animation: slide-in 0.3s ease-out;
}
@keyframes slide-out {
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-in {
from { transform: translateX(100%); opacity: 0; }
}
`;
Bfcache Optimization
// Back/Forward Cache allows instant back navigation
// Ensure your pages are bfcache-eligible
// 1. Remove unload listeners (breaks bfcache)
// ❌ Don't do this:
window.addEventListener('unload', () => {
// This prevents bfcache
});
// ✅ Do this instead:
window.addEventListener('pagehide', (event) => {
if (event.persisted) {
// Page is going into bfcache
// Clean up but don't prevent caching
}
});
// 2. Restore state on bfcache restore
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
// Page restored from bfcache
// Update any stale data
refreshStaleData();
}
});
// 3. Check bfcache eligibility
function checkBfcacheEligibility() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
for (const entry of entries) {
if (entry.entryType === 'back-forward-cache-restoration') {
console.log('Page was restored from bfcache');
}
}
});
// Note: This API is experimental
observer.observe({ type: 'back-forward-cache-restoration', buffered: true });
}
// 4. Test bfcache in DevTools
// Chrome DevTools > Application > Back/forward cache
// Click "Test back/forward cache" to see blocking reasons
Part 8: Performance Anti-Patterns to Avoid
Anti-Pattern 1: Blocking the Main Thread
// ❌ BAD: Synchronous heavy computation
function BadComponent({ data }: { data: LargeDataset }) {
// This blocks the main thread during render
const processed = data.items
.filter(item => complexFilter(item))
.map(item => expensiveTransform(item))
.sort((a, b) => complexSort(a, b));
return <List items={processed} />;
}
// ✅ GOOD: Async with loading state
function GoodComponent({ data }: { data: LargeDataset }) {
const [processed, setProcessed] = useState<ProcessedItem[]>([]);
const [isProcessing, setIsProcessing] = useState(true);
useEffect(() => {
// Process in chunks with yielding
processAsync(data.items).then(result => {
setProcessed(result);
setIsProcessing(false);
});
}, [data]);
if (isProcessing) return <ListSkeleton />;
return <List items={processed} />;
}
Anti-Pattern 2: Layout Thrashing
// ❌ BAD: Reading layout then writing (forced synchronous layout)
function BadAnimation(elements: HTMLElement[]) {
elements.forEach(el => {
// Read
const height = el.offsetHeight; // Forces layout
// Write
el.style.height = `${height * 2}px`; // Invalidates layout
// Next iteration: read forces layout calculation again
});
}
// ✅ GOOD: Batch reads, then batch writes
function GoodAnimation(elements: HTMLElement[]) {
// Batch all reads first
const heights = elements.map(el => el.offsetHeight);
// Then batch all writes
elements.forEach((el, i) => {
el.style.height = `${heights[i] * 2}px`;
});
}
// ✅ BETTER: Use requestAnimationFrame
function BetterAnimation(elements: HTMLElement[]) {
// Read phase
const heights = elements.map(el => el.offsetHeight);
// Write phase in next frame
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.height = `${heights[i] * 2}px`;
});
});
}
Anti-Pattern 3: Unnecessary Re-renders
// ❌ BAD: Creates new object/function every render
function BadParent() {
return (
<ExpensiveChild
config={{ theme: 'dark' }} // New object every render
onClick={() => doSomething()} // New function every render
/>
);
}
// ✅ GOOD: Memoize values
function GoodParent() {
const config = useMemo(() => ({ theme: 'dark' }), []);
const onClick = useCallback(() => doSomething(), []);
return <ExpensiveChild config={config} onClick={onClick} />;
}
// ✅ BETTER: Move constants outside component
const config = { theme: 'dark' };
function BetterParent() {
const onClick = useCallback(() => doSomething(), []);
return <ExpensiveChild config={config} onClick={onClick} />;
}
Anti-Pattern 4: Synchronous Third-Party Scripts
<!-- ❌ BAD: Blocks parsing and rendering -->
<head>
<script src="https://analytics.example.com/script.js"></script>
<script src="https://chat.example.com/widget.js"></script>
</head>
<!-- ✅ GOOD: Non-blocking loading -->
<head>
<!-- Critical CSS inline -->
<style>/* critical CSS */</style>
</head>
<body>
<!-- Content first -->
<main>...</main>
<!-- Scripts at end, deferred -->
<script src="https://analytics.example.com/script.js" defer></script>
<script src="https://chat.example.com/widget.js" async></script>
</body>
Conclusion: Performance Optimization Checklist
LCP Checklist
- Preload LCP image with
<link rel="preload"> - Use
fetchpriority="high"on LCP element - Inline critical CSS (<14KB)
- Use modern image formats (AVIF > WebP > JPEG)
- Implement responsive images with
srcset - Enable compression (Brotli > gzip)
- Use CDN for static assets
- Implement streaming SSR
- Send 103 Early Hints
INP Checklist
- Break long tasks with
scheduler.yield()orsetTimeout - Use
useTransitionfor non-urgent updates - Debounce/throttle expensive event handlers
- Virtualize long lists
- Move heavy computation to Web Workers
- Implement optimistic UI updates
- Avoid layout thrashing in event handlers
CLS Checklist
- Set explicit
widthandheighton images - Use
aspect-ratioCSS property - Reserve space for ads and embeds
- Use
font-display: optionalor adjust fallback metrics - Avoid inserting content above existing content
- Use CSS
containproperty - Use
transformfor animations instead of layout properties
General Performance
- Enable HTTP/2 or HTTP/3
- Implement service worker for caching
- Code-split by route
- Tree-shake unused code
- Lazy-load below-fold content
- Prefetch likely next navigations
- Monitor with RUM (Real User Monitoring)
- Set and enforce performance budgets
Target metrics (p75):
- LCP: ≤2.5s
- INP: ≤200ms
- CLS: ≤0.1
- TTFB: ≤800ms
- FCP: ≤1.8s
Performance is not a one-time optimization—it's an ongoing practice. Measure continuously, automate testing, and make performance a first-class engineering concern.
What did you think?