
Web Performance Optimization
- 333 installs
- 64 repo stars
- Updated July 18, 2026
- bobmatnyc/claude-mpm-skills
Audit and fix Core Web Vitals, bundle size, lazy loading, caching, and render bottlenecks before release or when users report slow pages on production web apps.
About
Guides Claude through systematic web performance optimization for production sites: diagnose Core Web Vitals regressions, shrink JavaScript and image payloads, apply caching and code-splitting, and validate improvements with profiling before ship.
- Core Web Vitals remediation
- Bundle and asset optimization
- Caching and lazy-load patterns
- Runtime profiling guidance
- Pre-launch performance checklist
Web Performance Optimization by the numbers
- 333 all-time installs (skills.sh)
- Ranked #714 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 6, 2026 (Skillselion catalog sync)
npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill web-performance-optimizationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 333 |
|---|---|
| repo stars | ★ 64 |
| Last updated | July 18, 2026 |
| Repository | bobmatnyc/claude-mpm-skills ↗ |
What it does
Audit and fix Core Web Vitals, bundle size, lazy loading, caching, and render bottlenecks before release or when users report slow pages on production web apps.
Files
Web Performance Optimization
Optimize web performance through Core Web Vitals, modern browser APIs (View Transitions, Speculation Rules), and framework-specific techniques.
When to Use This Skill
- Improving Lighthouse scores (target: 90+)
- Reducing page load times (target: <2.5s LCP)
- Optimizing Core Web Vitals for SEO rankings
- Implementing modern performance patterns (2025)
- Debugging performance bottlenecks
- Setting up continuous performance monitoring
Business Impact: 1 second delay = 7% conversion loss. 0.1s improvement = 8% increase in conversions.
Start Here: Quick Wins
High-ROI optimizations by time investment:
1 Hour Quick Wins (⭐⭐⭐⭐⭐ ROI):
- Add
loading="lazy"to below-fold images (40-60% weight reduction) - Enable compression (gzip/brotli) (70-80% transfer size reduction)
- Add
rel="preconnect"for critical origins (100-500ms savings)
1 Day Investments (⭐⭐⭐⭐ ROI):
- Implement code splitting (30-50% bundle reduction)
- Optimize LCP image with
fetchpriority="high"(200-400ms improvement) - Add basic service worker (instant repeat visits)
1 Week Comprehensive (⭐⭐⭐⭐⭐ ROI):
- Full caching strategy (HTTP headers + service workers)
- Bundle optimization (tree shaking, 40-60% reduction)
- Performance monitoring (Lighthouse CI + RUM)
→ See [quick-wins.md](./references/quick-wins.md) for complete implementation details
Core Web Vitals at a Glance
| Metric | Target | Weight | Key Optimization |
|---|---|---|---|
| LCP (Largest Contentful Paint) | <2.5s | 25% | Optimize images, preload critical resources |
| INP (Interaction to Next Paint) | <200ms | 30% | Reduce JavaScript, break up long tasks |
| CLS (Cumulative Layout Shift) | <0.1 | 25% | Reserve space, optimize fonts |
| TBT (Total Blocking Time) | <200ms | 30% | Code splitting, defer non-critical JS |
| FCP (First Contentful Paint) | <1.8s | 10% | Eliminate render-blocking resources |
→ See [core-web-vitals.md](./references/core-web-vitals.md) for deep dive on each metric
Modern Patterns (2025)
View Transitions API - Smooth page transitions without JavaScript
@view-transition { navigation: auto; }Speculation Rules API - Prerender pages for instant navigation React Server Components - Zero-bundle server components Priority Hints - fetchpriority="high" for LCP optimization Content Visibility - CSS-based rendering optimization
→ See [modern-patterns-2025.md](./references/modern-patterns-2025.md) for cutting-edge techniques
Navigation
Quick Start (Load First)
- [⚡ Quick Wins](./references/quick-wins.md) - Time-boxed optimizations (1hr/1day/1week) with ROI ratings. Load FIRST for immediate impact.
Deep Dives
- [📊 Core Web Vitals](./references/core-web-vitals.md) - LCP, INP, CLS optimization strategies with debugging workflows. Load when targeting specific metrics.
- [🔧 Optimization Techniques](./references/optimization-techniques.md) - Image, JavaScript, CSS, resource loading, caching patterns. Load when implementing specific optimizations.
Modern Features
- [✨ Modern Patterns 2025](./references/modern-patterns-2025.md) - View Transitions, Speculation Rules, RSC, Islands Architecture. Load when adopting cutting-edge patterns.
Framework-Specific
- [⚛️ Framework Patterns](./references/framework-specific.md) - Next.js, React, Vue, Vite, Astro, SvelteKit optimizations. Load for your framework.
Measurement & Monitoring
- [📈 Monitoring](./references/monitoring.md) - Lighthouse CI, RUM setup, performance budgets, debugging tools. Load when setting up continuous monitoring.
Key Reminders
- Measure first, optimize second - Always baseline with Lighthouse/WebPageTest before changes
- Focus on user-centric metrics - Core Web Vitals > vanity metrics
- Test on real devices - 53% of mobile users abandon after 3 seconds
- Monitor continuously - Performance degrades over time without vigilance
- Prioritize by ROI - Start with Quick Wins (high impact, low effort)
Red Flags
Stop and reconsider if:
- Optimizing without baseline measurement
- Focusing only on Lighthouse score (ignoring field data)
- Ignoring mobile performance (53% abandon rate after 3s)
- Loading all resources eagerly (no lazy loading)
- No caching strategy implemented
- Third-party scripts loaded synchronously
- Missing performance monitoring/budgets
Integration with Other Skills
- nextjs-core - Next.js Image component, font optimization, static generation
- react - Component optimization, memoization, code splitting
- vite - Build optimization, chunk splitting
- typescript-core - Type-safe performance patterns
Real-World Impact
Conversion Impact:
- Pinterest: 40% faster perceived wait = 15% more sign-ups + 15% more SEO traffic
- Zalando: 0.1s improvement = 0.7% revenue increase
- AutoAnything: 50% faster load = 12-13% sales increase
SEO Impact:
- Core Web Vitals are ranking factors (June 2021+)
- 75% of users passing Core Web Vitals = ranking boost
- Mobile-first indexing prioritizes mobile performance
---
Remember: Performance is a feature, not an afterthought. Every millisecond counts. Start with Quick Wins for immediate impact.
{
"name": "web-performance-optimization",
"version": "1.0.0",
"category": "universal",
"toolchain": null,
"framework": null,
"tags": [
"performance",
"async",
"api",
"testing",
"debugging"
],
"entry_point_tokens": 60,
"full_tokens": 39470,
"author": "bobmatnyc",
"license": "MIT",
"requires": [],
"updated": "2025-11-21",
"source_path": "web-performance-optimization.md",
"source": "https://github.com/bobmatnyc/claude-mpm",
"created": "2025-11-21",
"modified": "2025-11-21",
"maintainer": "Claude MPM Team",
"attribution_required": true,
"repository": "https://github.com/bobmatnyc/claude-mpm-skills"
}
Core Web Vitals Deep Dive
Comprehensive guide to understanding and optimizing Google's Core Web Vitals metrics.
Overview
Core Web Vitals are user-centric performance metrics that measure:
- Loading performance (LCP - Largest Contentful Paint)
- Interactivity (INP - Interaction to Next Paint)
- Visual stability (CLS - Cumulative Layout Shift)
These metrics are SEO ranking factors (since June 2021) and directly correlate with user satisfaction and conversions.
Business Impact:
- 100ms improvement in LCP = 1% increase in conversion rate
- CLS >0.25 = 24% higher bounce rate
- Poor Core Web Vitals = lower Google search rankings
---
Table of Contents
1. LCP (Largest Contentful Paint) 2. INP (Interaction to Next Paint) 3. CLS (Cumulative Layout Shift) 4. TTFB (Time to First Byte) 5. FCP (First Contentful Paint) 6. TBT (Total Blocking Time)
---
LCP (Largest Contentful Paint)
What it measures: Time until the largest content element becomes visible in the viewport
Targets:
- ✅ Good: ≤2.5 seconds
- ⚠️ Needs Improvement: 2.5-4.0 seconds
- ❌ Poor: >4.0 seconds
Weight in Lighthouse: 25%
SEO Impact: Direct ranking factor for Google search
What Counts as LCP Element
The LCP element is the largest visible element in the viewport:
<img>elements<image>elements inside<svg><video>elements (poster image or first frame)- Background images loaded via
url() - Block-level text elements
Finding your LCP element:
// In Chrome DevTools Console
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP element:', lastEntry.element);
console.log('LCP time:', lastEntry.startTime);
console.log('LCP size:', lastEntry.size);
}).observe({ entryTypes: ['largest-contentful-paint'] });
// Or use web-vitals library
import { onLCP } from 'web-vitals';
onLCP((metric) => {
console.log('LCP:', metric.value);
console.log('Rating:', metric.rating);
console.log('Element:', metric.entries[0].element);
});LCP Breakdown (Diagnostic Workflow)
LCP consists of 4 phases:
1. TTFB (Time to First Byte) - Server response time 2. Resource load delay - Time from TTFB to resource load start 3. Resource load time - Time to download resource 4. Render delay - Time from resource loaded to rendered
How to break down LCP:
# Use curl to measure TTFB
curl -w "@curl-format.txt" -o /dev/null -s https://yoursite.com
# Look in Chrome DevTools Performance panel:
# - TTFB: Time from navigation to first byte received
# - Resource load delay: Gap before LCP resource fetch starts
# - Resource load time: Network tab (resource timing)
# - Render delay: Performance panel (rendering time)Example breakdown for 4.43s LCP:
- TTFB: 3.67s (83% of LCP time) ← PRIMARY BOTTLENECK
- Resource load delay: 0.2s
- Resource load time: 0.4s
- Render delay: 0.16s
Optimization priority: Fix the largest component first (TTFB in this case)
---
LCP Optimization Strategies
Strategy 1: Optimize TTFB (if TTFB >800ms)
If TTFB is the bottleneck (common for server-rendered pages):
See TTFB section for detailed strategies.
Quick wins:
- Enable CDN edge caching
- Add Redis caching layer for API responses
- Optimize database queries (fix N+1 queries, add indexes)
- Enable HTTP/2 or HTTP/3
- Enable compression (Brotli)
---
Strategy 2: Preload LCP Resource (if resource load delay is high)
Problem: Browser doesn't discover LCP resource until late (after parsing CSS/JS)
<!-- ❌ BAD: Browser discovers image late (after parsing CSS) -->
<style>
.hero { background: url('hero.jpg'); }
</style>
<div class="hero"></div>
<!-- Discovery delay: 200-500ms -->
<!-- ✅ GOOD: Preload LCP resource -->
<link rel="preload" as="image" href="hero.jpg" fetchpriority="high">
<style>
.hero { background: url('hero.jpg'); }
</style>
<div class="hero"></div>
<!-- Browser starts loading immediately -->
<!-- ✅ BEST: Use <img> with fetchpriority="high" -->
<img src="hero.jpg" alt="Hero" width="1200" height="600"
fetchpriority="high" loading="eager">Impact: Reduces LCP by 200-400ms
---
Strategy 3: Optimize Image (if resource load time is high)
Use modern formats (WebP, AVIF):
<picture>
<!-- AVIF: 50% smaller than JPEG -->
<source srcset="hero.avif" type="image/avif">
<!-- WebP: 30% smaller than JPEG -->
<source srcset="hero.webp" type="image/webp">
<!-- JPEG fallback -->
<img src="hero.jpg" alt="Hero" width="1200" height="600">
</picture>Convert images:
# JPEG → WebP (30% smaller)
cwebp -q 85 hero.jpg -o hero.webp
# JPEG → AVIF (50% smaller)
avifenc -s 5 hero.jpg hero.avif
# Batch conversion
for img in *.jpg; do
cwebp -q 85 "$img" -o "${img%.jpg}.webp"
avifenc -s 5 "$img" "${img%.jpg}.avif"
doneOptimize compression:
# JPEG optimization (lossless)
jpegoptim --strip-all hero.jpg
# JPEG optimization (lossy, better compression)
jpegoptim --max=85 --strip-all hero.jpgUse CDN for automatic optimization:
- Cloudflare Images:
https://yoursite.com/cdn-cgi/image/format=auto,quality=85/hero.jpg - Cloudinary:
https://res.cloudinary.com/yourcloud/image/upload/f_auto,q_auto/hero.jpg - imgix:
https://yoursite.imgix.net/hero.jpg?auto=format,compress
Impact: 30-50% faster image load time
---
Strategy 4: Eliminate Render-Blocking Resources (if render delay is high)
Problem: CSS/JS blocking LCP rendering
<!-- ❌ BAD: Render-blocking CSS/JS in <head> -->
<head>
<link rel="stylesheet" href="styles.css"> <!-- Blocks rendering -->
<script src="app.js"></script> <!-- Blocks parsing -->
</head>
<!-- Render delay: 200-500ms -->
<!-- ✅ GOOD: Inline critical CSS, defer non-critical -->
<head>
<!-- Inline critical above-fold CSS (5-10KB) -->
<style>
.hero { height: 100vh; background: #333; }
/* ... other critical styles */
</style>
<!-- Load non-critical CSS asynchronously -->
<link rel="preload" href="styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>
<!-- Defer JavaScript -->
<script src="app.js" defer></script>
</head>Extract critical CSS automatically:
npm install -g critical
critical index.html --base ./public --inline --minify > index-optimized.htmlImpact: Reduces LCP by 100-300ms
---
Strategy 5: Responsive Images (reduce download size)
Serve appropriately sized images:
<!-- ❌ BAD: Same large image for all devices -->
<img src="hero-2000.jpg" alt="Hero">
<!-- Mobile downloads 2MB desktop image for 400px screen -->
<!-- ✅ GOOD: Responsive images with srcset -->
<img
src="hero-800.jpg"
srcset="
hero-400.jpg 400w,
hero-800.jpg 800w,
hero-1200.jpg 1200w,
hero-1600.jpg 1600w
"
sizes="
(max-width: 600px) 400px,
(max-width: 900px) 800px,
1200px
"
alt="Hero"
width="1600"
height="900"
>
<!-- Mobile downloads only 400px version (150KB vs 2MB) -->Impact: 70-90% smaller downloads for mobile users
---
Advanced LCP Techniques
Early Hints (103 Status Code)
Send preload hints before final response:
# Nginx configuration
location / {
# Send early hints (HTTP 103)
add_header Link "</hero.jpg>; rel=preload; as=image; fetchpriority=high";
add_header Link "</styles.css>; rel=preload; as=style";
proxy_pass http://backend;
}Impact: 100-200ms faster LCP (browser starts loading resources earlier)
Browser support: Chrome 103+, Firefox 103+
---
Adaptive Loading (Network-Aware)
Serve different images based on connection speed:
const connection = navigator.connection || navigator.mozConnection;
let imageSrc = 'hero-hq.jpg'; // Default: high quality
if (connection) {
if (connection.effectiveType === '4g') {
imageSrc = 'hero-hq.avif'; // Fast: AVIF
} else if (connection.effectiveType === '3g') {
imageSrc = 'hero-mq.webp'; // Medium: WebP
} else {
imageSrc = 'hero-lq.jpg'; // Slow: Low quality JPEG
}
}
document.querySelector('.hero img').src = imageSrc;Impact: 50% faster LCP on slow networks
---
LCP Debugging Checklist
- [ ] Identify LCP element (use Performance Observer)
- [ ] Break down LCP into 4 phases (TTFB, load delay, load time, render)
- [ ] Optimize bottleneck phase first
- [ ] If TTFB >800ms: See TTFB section
- [ ] If load delay high: Preload LCP resource with fetchpriority="high"
- [ ] If load time high: Use modern formats (WebP/AVIF), optimize compression, use CDN
- [ ] If render delay high: Eliminate render-blocking resources, inline critical CSS
- [ ] Verify LCP <2.5s in field data (web-vitals library)
---
INP (Interaction to Next Paint)
What it measures: Responsiveness - time from user interaction to visual update
Replaces: FID (First Input Delay) as of March 2024
Targets:
- ✅ Good: ≤200ms
- ⚠️ Needs Improvement: 200-500ms
- ❌ Poor: >500ms
Weight in Lighthouse: 10%
User Experience Impact:
- INP >200ms: Users perceive UI as sluggish
- INP >500ms: Users perceive UI as broken
- Every 100ms improvement = 2% increase in engagement
What INP Measures
INP tracks ALL interactions during page lifetime:
- Clicks
- Taps
- Keyboard presses
INP breakdown: 1. Input delay - Time from interaction to event handler start 2. Processing time - Event handler execution time 3. Presentation delay - Time from handler end to visual update
Worst interaction wins - INP reports 98th percentile interaction
---
Finding Slow Interactions
// Use web-vitals library
import { onINP } from 'web-vitals';
onINP((metric) => {
console.log('INP:', metric.value);
console.log('Rating:', metric.rating);
console.log('Attribution:', metric.attribution);
// Log slow interactions (>200ms)
if (metric.value > 200) {
console.log('Slow interaction detected!');
console.log('Element:', metric.attribution.interactionTarget);
console.log('Type:', metric.attribution.interactionType);
console.log('Input delay:', metric.attribution.inputDelay);
console.log('Processing time:', metric.attribution.processingDuration);
console.log('Presentation delay:', metric.attribution.presentationDelay);
// Send to analytics
sendToAnalytics({
name: 'INP',
value: metric.value,
element: metric.attribution.interactionTarget,
type: metric.attribution.interactionType
});
}
});Chrome DevTools Performance panel: 1. Record interaction 2. Look for long "Task" blocks (>50ms) 3. Identify which event handlers are slow
---
INP Optimization Strategies
Strategy 1: Break Up Long Tasks
Problem: Long-running tasks block main thread, causing input delay
JavaScript runs single-threaded - long tasks prevent UI updates
// ❌ BAD: Long task blocks main thread (300ms)
function processItems(items) {
items.forEach(item => {
// Each iteration takes 10ms
// 30 items × 10ms = 300ms blocked
heavyComputation(item);
});
}
// User clicks button → Waits 300ms → Handler runs → INP: 300ms+
// ✅ GOOD: Break into chunks with scheduler.yield()
async function processItems(items) {
for (const item of items) {
heavyComputation(item);
// Yield to main thread every item
if ('scheduler' in window && 'yield' in scheduler) {
await scheduler.yield(); // Let browser handle pending interactions
} else {
// Fallback: setTimeout
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
// User clicks button → Handled immediately → INP: <50ms
// ✅ BETTER: Batch processing (yield every N items)
async function processItems(items) {
for (let i = 0; i < items.length; i++) {
heavyComputation(items[i]);
// Yield every 5 items
if (i % 5 === 0) {
await scheduler.yield();
}
}
}Rule of thumb: Tasks should run <50ms. Break longer tasks into chunks.
Impact: Reduces INP by 50-80% for CPU-heavy operations
---
Strategy 2: Debounce/Throttle Event Handlers
Problem: Event handlers firing too frequently (scroll, resize, input)
// ❌ BAD: Handler runs on every scroll event (hundreds per second)
window.addEventListener('scroll', () => {
updateParallaxEffect(); // Expensive computation
});
// INP: 200-500ms (constant main thread blocking)
// ✅ GOOD: Debounce (run after user stops scrolling)
import { debounce } from 'lodash-es';
window.addEventListener('scroll', debounce(() => {
updateParallaxEffect();
}, 100)); // Run 100ms after last scroll event
// ✅ ALSO GOOD: Throttle (run at most once per N ms)
import { throttle } from 'lodash-es';
window.addEventListener('scroll', throttle(() => {
updateParallaxEffect();
}, 100)); // Run at most once every 100msWhen to use:
- Debounce: Search input, window resize (wait until user finishes)
- Throttle: Scroll events, mouse move (limit frequency)
Impact: 80-95% reduction in event handler calls
---
Strategy 3: Use Web Workers for CPU-Intensive Tasks
Offload heavy computations to background thread:
// ❌ BAD: CPU-intensive task on main thread
function processData(data) {
const result = complexCalculation(data); // Blocks main thread for 500ms
return result;
}
button.addEventListener('click', () => {
const result = processData(largeDataset); // INP: 500ms+
updateUI(result);
});
// ✅ GOOD: Use Web Worker
// worker.js
self.addEventListener('message', (e) => {
const result = complexCalculation(e.data);
self.postMessage(result);
});
// main.js
const worker = new Worker('worker.js');
button.addEventListener('click', () => {
worker.postMessage(largeDataset); // Non-blocking
// INP: <50ms (main thread free)
});
worker.addEventListener('message', (e) => {
updateUI(e.data); // Update UI when ready
});What to offload to Web Workers:
- Data processing (sorting, filtering, transforming large arrays)
- Image manipulation
- Cryptography
- Complex calculations
What NOT to offload:
- DOM manipulation (workers can't access DOM)
- Very small tasks (overhead not worth it)
Impact: Reduces INP to <50ms for heavy computations
---
Strategy 4: Optimize Event Handler Logic
Reduce processing time by optimizing handler code:
// ❌ BAD: Expensive DOM queries in handler
button.addEventListener('click', () => {
const items = document.querySelectorAll('.item'); // Query every click
items.forEach(item => {
item.classList.add('active');
});
});
// ✅ GOOD: Cache DOM queries
const items = document.querySelectorAll('.item'); // Query once
button.addEventListener('click', () => {
items.forEach(item => {
item.classList.add('active');
});
});
// ✅ BETTER: Event delegation (single listener)
container.addEventListener('click', (e) => {
if (e.target.matches('.item')) {
e.target.classList.add('active');
}
});
// Reduces listeners from N to 1Impact: 30-60% faster event handlers
---
Strategy 5: Use requestIdleCallback for Non-Critical Work
Defer non-critical work until browser is idle:
// ❌ BAD: Analytics tracking blocks interaction
button.addEventListener('click', () => {
// Critical: Update UI
updateUI();
// Non-critical: Send analytics (blocks for 50ms)
sendAnalytics(); // INP: 50ms penalty
});
// ✅ GOOD: Defer non-critical work
button.addEventListener('click', () => {
// Critical: Update UI immediately
updateUI(); // INP: <10ms
// Non-critical: Schedule for idle time
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
sendAnalytics();
});
} else {
setTimeout(sendAnalytics, 0);
}
});Impact: 20-50ms faster INP
---
INP Debugging Checklist
- [ ] Identify slow interactions with web-vitals library
- [ ] Record interaction in Chrome DevTools Performance panel
- [ ] Look for long tasks (>50ms)
- [ ] Break up long tasks with scheduler.yield()
- [ ] Debounce/throttle frequent event handlers
- [ ] Offload CPU-intensive work to Web Workers
- [ ] Optimize event handler logic (cache DOM queries, event delegation)
- [ ] Defer non-critical work with requestIdleCallback
- [ ] Verify INP <200ms in field data
---
CLS (Cumulative Layout Shift)
What it measures: Visual stability - how much content shifts unexpectedly during page load
Targets:
- ✅ Good: ≤0.1
- ⚠️ Needs Improvement: 0.1-0.25
- ❌ Poor: >0.25
Weight in Lighthouse: 25%
SEO Impact: Direct ranking factor for Google search (since June 2021)
User Experience Impact:
- CLS >0.1: Users find layout shifts annoying
- CLS >0.25: 24% higher bounce rate
- Each 0.1 increase = 3% decrease in conversions
How CLS is Calculated
CLS = Impact Fraction × Distance Fraction- Impact Fraction: % of viewport affected by shift
- Distance Fraction: Distance element moved / viewport height
Example:
- Element takes up 50% of viewport (impact fraction = 0.5)
- Element shifts down by 25% of viewport height (distance fraction = 0.25)
- CLS = 0.5 × 0.25 = 0.125 (Poor)
CLS is cumulative: All layout shifts during page lifetime are added up
---
Diagnostic Workflow
Step 1: Enable Layout Shift Regions in Chrome DevTools
1. Open DevTools (F12) 2. Press Cmd+Shift+P (Mac) or Ctrl+Shift+P (Windows) 3. Type "Show Layout Shift Regions" 4. Enable the setting 5. Reload page - shifted elements will be highlighted in blue
Step 2: Record Performance Trace
1. Go to Performance panel in DevTools 2. Click Record (Cmd+E) 3. Load your page (or trigger interactions) 4. Stop recording 5. Look for red "Layout Shift" bars in the Experience section 6. Click each bar to see which element shifted
Step 3: Identify Shifting Elements
// Log layout shifts in console
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // Ignore user-initiated shifts
console.log('Layout shift detected!');
console.log('Value:', entry.value);
console.log('Sources:', entry.sources);
entry.sources.forEach(source => {
console.log('Element:', source.node);
console.log('Previous rect:', source.previousRect);
console.log('Current rect:', source.currentRect);
});
}
}).observe({ entryTypes: ['layout-shift'] });Step 4: Measure CLS in Production
import { onCLS } from 'web-vitals';
onCLS((metric) => {
console.log('CLS:', metric.value);
console.log('Rating:', metric.rating);
// Log details for poor CLS
if (metric.value > 0.1) {
console.log('Poor CLS detected!');
console.log('Entries:', metric.entries);
// Send to analytics
sendToAnalytics({
name: 'CLS',
value: metric.value,
rating: metric.rating,
entries: metric.entries.map(entry => ({
value: entry.value,
sources: entry.sources.map(source => ({
node: source.node?.outerHTML.substring(0, 100),
previousRect: source.previousRect,
currentRect: source.currentRect
}))
}))
});
}
});---
Common Causes and Fixes
Cause 1: Images Without Dimensions (MOST COMMON)
Impact: 50-80% of CLS issues
Problem:
<!-- ❌ BAD: Browser doesn't know image size until loaded -->
<img src="hero.jpg" alt="Hero">
<!-- Page loads → Text appears → Image loads → Text shifts down → CLS: 0.25+ -->Solution 1: Set explicit dimensions
<!-- ✅ GOOD: Browser reserves space before image loads -->
<img src="hero.jpg" alt="Hero" width="1200" height="600">
<!-- Page loads → Space reserved → Image loads → No shift! CLS: 0 -->Solution 2: Use aspect-ratio CSS (modern)
<img src="hero.jpg" alt="Hero" style="aspect-ratio: 16/9; width: 100%;">
<!-- Responsive + prevents CLS -->Solution 3: CSS for all images
img {
max-width: 100%;
height: auto;
/* aspect-ratio preserved from width/height attributes */
}
/* Or use aspect-ratio for responsive images */
.responsive-img {
aspect-ratio: 16/9;
width: 100%;
object-fit: cover;
}For background images:
.hero {
background: url('hero.jpg') center/cover;
aspect-ratio: 16/9;
/* Reserve space with aspect ratio */
}Impact: Reduces CLS by 0.2-0.4 (50-80% improvement)
---
Cause 2: Web Fonts Loading
Impact: 20-40% of CLS issues
Problem:
/* ❌ BAD: FOIT (Flash of Invisible Text) or FOUT (Flash of Unstyled Text) */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
/* No font-display → Default causes layout shift */
}
body {
font-family: 'CustomFont', sans-serif;
}
/* Page loads → Invisible text (FOIT) → Font loads → Text appears → Shift! */Solution 1: Use font-display: swap
/* ✅ GOOD: Show fallback immediately, swap when custom font loads */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* Show fallback immediately */
}font-display values:
swap: Show fallback immediately, swap when font loads (best for CLS)optional: Use custom font if loaded quickly, otherwise use fallbackfallback: Brief invisible period, then show fallback if font not loadedblock: Invisible text until font loads (causes CLS, avoid!)
Solution 2: Preload critical fonts
<!-- Preload fonts to load them earlier -->
<link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" crossorigin>
<style>
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
}
</style>Solution 3: Match fallback font metrics (advanced)
/* Adjust fallback font to match custom font dimensions */
@font-face {
font-family: 'CustomFont-Fallback';
src: local('Arial');
ascent-override: 105%;
descent-override: 35%;
line-gap-override: 10%;
size-adjust: 95%;
}
body {
font-family: 'CustomFont', 'CustomFont-Fallback', sans-serif;
}
/* Minimizes layout shift when swapping fonts */Solution 4: Use system fonts (zero CLS)
/* ✅ BEST: System fonts load instantly, zero CLS */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
}Impact: Reduces CLS by 0.1-0.2
---
Cause 3: Ads Without Reserved Space
Impact: 30-50% of CLS issues (especially on content sites)
Problem:
<!-- ❌ BAD: Ad loads dynamically, pushes content down -->
<div id="ad-slot"></div>
<script>loadAd('ad-slot');</script>
<p>Content below ad...</p>
<!-- Content loads → Ad loads → Content shifts → CLS: 0.3+ -->Solution: Reserve space with min-height and aspect-ratio
<!-- ✅ GOOD: Reserve space before ad loads -->
<div class="ad-container" style="min-height: 250px; aspect-ratio: 16/9;">
<div id="ad-slot"></div>
</div>
<p>Content below ad...</p>
<!-- Space reserved → Ad loads into reserved space → No shift! -->CSS approach:
.ad-container {
min-height: 250px; /* Minimum height for ad slot */
aspect-ratio: 16/9; /* Maintain aspect ratio */
background: #f0f0f0; /* Placeholder background */
display: flex;
align-items: center;
justify-content: center;
}
.ad-container::before {
content: 'Advertisement';
color: #999;
font-size: 14px;
}For multiple ad sizes:
/* Mobile: 300×250 */
@media (max-width: 767px) {
.ad-container {
min-height: 250px;
aspect-ratio: 6/5;
}
}
/* Desktop: 728×90 */
@media (min-width: 768px) {
.ad-container {
min-height: 90px;
aspect-ratio: 728/90;
}
}Impact: Reduces CLS by 0.2-0.3
---
Cause 4: Dynamic Content Injection
Impact: 20-40% of CLS issues
Problem:
// ❌ BAD: Insert content above existing content
fetch('/api/banner')
.then(response => response.text())
.then(html => {
document.getElementById('header').insertAdjacentHTML('afterbegin', html);
// Existing content shifts down → CLS!
});Solution 1: Reserve space with min-height
<div id="banner-slot" style="min-height: 100px;">
<!-- Banner loads here -->
</div>
<script>
fetch('/api/banner')
.then(response => response.text())
.then(html => {
document.getElementById('banner-slot').innerHTML = html;
// No shift - space already reserved
});
</script>Solution 2: Append instead of prepend
// ✅ BETTER: Append at end (doesn't shift existing content)
fetch('/api/related-articles')
.then(response => response.text())
.then(html => {
document.getElementById('content').insertAdjacentHTML('beforeend', html);
// No shift for existing content
});Solution 3: Use skeleton screens
<!-- Skeleton while loading -->
<div class="banner-skeleton" style="height: 100px; background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);">
<div class="skeleton-line"></div>
</div>
<script>
fetch('/api/banner')
.then(response => response.text())
.then(html => {
// Replace skeleton with real content (same height → no shift)
document.querySelector('.banner-skeleton').outerHTML = html;
});
</script>Impact: Reduces CLS by 0.1-0.3
---
Cause 5: Animations Causing Layout
Impact: 10-20% of CLS issues
Problem:
/* ❌ BAD: Animating properties that trigger layout */
.modal {
transition: height 0.3s, width 0.3s;
}
.modal.open {
height: 500px; /* Triggers layout recalculation */
width: 600px; /* Triggers layout recalculation */
}
/* Every frame recalculates layout → CLS */Solution: Use transform and opacity only
/* ✅ GOOD: Transform/opacity don't trigger layout */
.modal {
transform: scale(0) translateY(-50%);
opacity: 0;
transition: transform 0.3s, opacity 0.3s;
will-change: transform, opacity; /* GPU acceleration hint */
}
.modal.open {
transform: scale(1) translateY(0); /* GPU-accelerated */
opacity: 1;
}
/* No layout recalculation → No CLS */Properties that DON'T cause layout shifts (GPU-accelerated):
- ✅
transform(translate, scale, rotate) - ✅
opacity - ✅
filter
Properties that DO cause layout shifts (AVOID in animations):
- ❌
width,height - ❌
top,left,right,bottom(usetransform: translate()instead) - ❌
margin,padding - ❌
border-width - ❌
font-size
Impact: Reduces CLS by 0.05-0.15
---
Complete Example: Zero-CLS Page
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zero-CLS Page Example</title>
<!-- Preload critical fonts -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<style>
/* System fonts fallback to prevent FOIT */
@font-face {
font-family: 'MainFont';
src: url('/fonts/main.woff2') format('woff2');
font-display: swap; /* Show fallback immediately */
}
body {
font-family: 'MainFont', -apple-system, BlinkMacSystemFont, sans-serif;
margin: 0;
}
/* All images have dimensions */
img {
max-width: 100%;
height: auto;
}
/* Reserve space for ads */
.ad-container {
min-height: 250px;
aspect-ratio: 16/9;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
}
/* GPU-accelerated animations only */
.modal {
transform: translateY(100%);
opacity: 0;
transition: transform 0.3s, opacity 0.3s;
will-change: transform, opacity;
}
.modal.open {
transform: translateY(0);
opacity: 1;
}
/* Reserve space for dynamic content */
.banner-slot {
min-height: 100px;
background: #f5f5f5;
}
</style>
</head>
<body>
<!-- Hero image with explicit dimensions -->
<img src="hero.jpg" alt="Hero" width="1200" height="600" fetchpriority="high">
<!-- Banner with reserved space -->
<div id="banner-slot" class="banner-slot"></div>
<!-- Ad slot with reserved space -->
<div class="ad-container">
<div id="ad-slot"></div>
</div>
<!-- Main content -->
<main>
<h1>Welcome</h1>
<p>Your content here...</p>
<!-- More images with dimensions -->
<img src="content-1.jpg" alt="Content 1" width="800" height="600" loading="lazy">
<img src="content-2.jpg" alt="Content 2" width="800" height="600" loading="lazy">
</main>
<script>
// Load dynamic content into reserved space
fetch('/api/banner')
.then(r => r.text())
.then(html => {
document.getElementById('banner-slot').innerHTML = html;
});
// Load ads into reserved space
loadAd('ad-slot');
</script>
</body>
</html>Expected CLS: <0.05 (Good) ✅
---
CLS Debugging Checklist
- [ ] All images have width/height attributes or aspect-ratio CSS
- [ ] Fonts use font-display: swap (or system fonts)
- [ ] Critical fonts preloaded
- [ ] Ads have reserved space (min-height + aspect-ratio)
- [ ] Dynamic content uses skeleton screens or reserved space
- [ ] Animations use transform/opacity only (no layout properties)
- [ ] Layout Shift Regions enabled in DevTools to identify shifts
- [ ] CLS measured in production with web-vitals library
- [ ] CLS <0.1 for 75th percentile of users
---
TTFB (Time to First Byte)
What it measures: Server response time - time from navigation start to first byte received
Targets:
- ✅ Good: ≤800ms
- ⚠️ Needs Improvement: 800-1800ms
- ❌ Poor: >1800ms
Weight in Lighthouse: Not directly scored, but affects LCP and FCP
Critical because: TTFB is the foundation for all other metrics. Slow TTFB delays everything.
TTFB Breakdown
TTFB consists of: 1. DNS lookup - Resolve domain to IP (~20-120ms) 2. TCP connection - Establish connection (~20-100ms) 3. TLS handshake - SSL/TLS negotiation (~100-200ms for TLS 1.2, ~50-100ms for TLS 1.3) 4. Server processing - Backend generates response (varies widely: 50ms to 5s+)
Measuring TTFB breakdown:
# Create curl timing format
cat > curl-format.txt << 'EOF'
DNS lookup: %{time_namelookup}s\n
TCP connection: %{time_connect}s\n
TLS handshake: %{time_appconnect}s\n
Server processing: %{time_starttransfer}s (TTFB)\n
Total time: %{time_total}s\n
EOF
# Measure TTFB
curl -w "@curl-format.txt" -o /dev/null -s https://yoursite.com
# Example output:
# DNS lookup: 0.023s ✅ Good
# TCP connection: 0.045s ✅ Good
# TLS handshake: 0.128s ✅ Good (TLS 1.2)
# Server processing: 3.670s ❌ PROBLEM - This is TTFB
# Total time: 4.234s---
Diagnostic Workflow
Step 1: Identify bottleneck phase
| Phase | Time | Status | Fix |
|---|---|---|---|
| DNS lookup >100ms | ❌ Slow | Use faster DNS provider (Cloudflare, Google) | |
| TCP connection >100ms | ❌ Slow | Enable Keep-Alive, use CDN | |
| TLS handshake >200ms | ❌ Slow | Enable TLS 1.3, optimize certificate chain | |
| Server processing >500ms | ❌ Slow | MOST COMMON - See optimization strategies below |
Step 2: Profile backend (if server processing is slow)
Use Application Performance Monitoring (APM) tools:
- New Relic
- Datadog
- Sentry Performance
- AWS X-Ray (for AWS)
- Google Cloud Profiler
What to look for:
- Slow database queries (>100ms)
- N+1 query problems
- Missing database indexes
- External API call latency
- Cold start penalties (serverless functions)
- Cache miss ratios
Step 3: Optimize bottleneck
---
TTFB Optimization Strategies
For detailed implementation, see optimization-techniques.md.
Strategy 1: Database Optimization
Fix N+1 queries:
# ❌ BAD: 1 + N queries (2000ms for 100 posts)
posts = Post.objects.all()
for post in posts:
print(post.author.name) # N queries
# ✅ GOOD: 1 query with JOIN (50ms for 100 posts)
posts = Post.objects.select_related('author').all()
for post in posts:
print(post.author.name) # No additional queryAdd indexes:
-- ❌ BAD: Full table scan (2300ms)
SELECT * FROM orders WHERE user_id = 123 AND created_at > '2025-01-01';
-- ✅ GOOD: Index scan (15ms)
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);
SELECT * FROM orders WHERE user_id = 123 AND created_at > '2025-01-01';Impact: 10-100x faster queries
---
Strategy 2: Server-Side Caching (Redis)
Cache expensive operations:
import redis
import json
redis_client = redis.Redis(host='localhost', port=6379)
def get_user_profile(user_id):
# Check cache first
cache_key = f"user_profile:{user_id}"
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached) # Cache hit (1-2ms)
# Cache miss - query database
user = db.query(f"SELECT * FROM users WHERE id = {user_id}") # 100ms
# Cache for 5 minutes
redis_client.setex(cache_key, 300, json.dumps(user))
return user
# Performance: 95% cache hit rate → 50x faster (2ms vs 100ms)Impact: 50-100x faster for frequently accessed data
---
Strategy 3: CDN Edge Caching
Cache static and semi-static content at edge:
# Nginx cache configuration
location ~* \.(jpg|jpeg|png|gif|webp|avif|css|js|woff2)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# API responses: short cache with stale-while-revalidate
location /api/ {
add_header Cache-Control "max-age=60, stale-while-revalidate=600";
proxy_pass http://backend;
}Impact: 90-95% of requests served from edge (50-200ms faster)
---
Strategy 4: HTTP/2 or HTTP/3
Enable multiplexing and header compression:
server {
listen 443 ssl http2; # Enable HTTP/2
server_name yoursite.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
}Impact: 30-50% faster for multiple resources
---
Strategy 5: Compression (Brotli)
http {
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css text/javascript application/json;
# Fallback to gzip
gzip on;
gzip_types text/plain text/css text/javascript application/json;
}Impact: 20-30% smaller responses, 5-10% faster TTFB
---
Strategy 6: Cold Start Mitigation (Serverless)
Reuse database connections:
// ❌ BAD: New connection every invocation (1-2s cold start)
exports.handler = async (event) => {
const db = await connectToDatabase();
const result = await db.query('SELECT * FROM users');
await db.close();
return result;
};
// ✅ GOOD: Reuse connection across invocations
let db;
exports.handler = async (event) => {
if (!db) {
db = await connectToDatabase(); // Only on cold start
}
const result = await db.query('SELECT * FROM users');
return result;
};Impact: Eliminates 1-3s cold start penalty
---
TTFB Debugging Checklist
- [ ] Measure TTFB breakdown with curl
- [ ] Identify bottleneck phase (DNS, TCP, TLS, or server)
- [ ] If server processing >500ms:
- [ ] Profile with APM tool
- [ ] Fix N+1 database queries
- [ ] Add missing indexes
- [ ] Implement Redis caching
- [ ] Enable CDN edge caching
- [ ] Enable HTTP/2 or HTTP/3
- [ ] Enable compression (Brotli)
- [ ] Fix cold start issues (serverless)
- [ ] Verify TTFB <800ms
Target: TTFB <800ms (Good), <1800ms (Acceptable)
---
FCP (First Contentful Paint)
What it measures: Time until first text or image is rendered
Targets:
- ✅ Good: ≤1.8 seconds
- ⚠️ Needs Improvement: 1.8-3.0 seconds
- ❌ Poor: >3.0 seconds
Weight in Lighthouse: 10%
User Experience: FCP is when user sees "something" - indicates page is loading
FCP vs LCP
- FCP: First content (any text/image)
- LCP: Largest content (main hero image/text block)
Example:
- FCP: 0.8s (navigation bar appears)
- LCP: 2.2s (hero image appears)
---
FCP Optimization Strategies
Strategy 1: Eliminate Render-Blocking Resources
Problem: CSS/JS in <head> blocks rendering
<!-- ❌ BAD: Blocking CSS -->
<head>
<link rel="stylesheet" href="styles.css"> <!-- Blocks FCP -->
</head>
<!-- ✅ GOOD: Inline critical CSS, defer non-critical -->
<head>
<style>
/* Inline critical CSS (above-fold styles, 5-10KB) */
nav { background: #333; }
.hero { height: 100vh; }
</style>
<!-- Load non-critical CSS asynchronously -->
<link rel="preload" href="styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>
</head>Impact: 200-500ms faster FCP
---
Strategy 2: Defer Non-Critical JavaScript
<!-- ❌ BAD: Blocking JS -->
<head>
<script src="app.js"></script> <!-- Blocks parsing and FCP -->
</head>
<!-- ✅ GOOD: Defer JS -->
<head>
<script src="app.js" defer></script> <!-- Doesn't block FCP -->
<!-- Or async for non-dependent scripts -->
<script src="analytics.js" async></script>
</head>Impact: 100-300ms faster FCP
---
Strategy 3: Optimize Font Loading
<!-- Preload critical fonts -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<style>
@font-face {
font-family: 'MainFont';
src: url('/fonts/main.woff2') format('woff2');
font-display: swap; /* Show fallback immediately */
}
</style>Impact: 100-300ms faster FCP
---
Strategy 4: Reduce Server Response Time (TTFB)
FCP depends on TTFB - see TTFB section for optimization strategies.
---
FCP Debugging Checklist
- [ ] Inline critical CSS (<10KB)
- [ ] Defer non-critical CSS
- [ ] Defer/async JavaScript
- [ ] Preload critical fonts with font-display: swap
- [ ] Optimize TTFB <800ms
- [ ] Verify FCP <1.8s
---
TBT (Total Blocking Time)
What it measures: Total time main thread is blocked during page load
Targets:
- ✅ Good: ≤200ms
- ⚠️ Needs Improvement: 200-600ms
- ❌ Poor: >600ms
Weight in Lighthouse: 30% (highest weight!)
User Experience: TBT indicates how responsive page is during load
How TBT is Calculated
TBT = Sum of blocking time for all long tasks (>50ms) between FCP and TTI
Example:
- Task 1: 70ms → Blocking time: 20ms (70 - 50)
- Task 2: 120ms → Blocking time: 70ms (120 - 50)
- Task 3: 40ms → Blocking time: 0ms (below threshold)
- TBT = 20 + 70 = 90ms
Long task threshold: 50ms
Why 50ms? Human perception threshold - tasks <50ms feel instant
---
TBT Optimization Strategies
Strategy 1: Code Splitting
Break large bundles into smaller chunks:
// ❌ BAD: Large bundle (500KB) = long tasks
import { HeavyComponent } from './heavy';
// ✅ GOOD: Code split by route
const HeavyComponent = lazy(() => import('./heavy'));Impact: 30-50% TBT reduction
---
Strategy 2: Defer Non-Critical JavaScript
<!-- Load non-critical JS after page interactive -->
<script src="analytics.js" defer></script>
<script src="chat-widget.js" defer></script>Impact: 20-40% TBT reduction
---
Strategy 3: Break Up Long Tasks
Use scheduler.yield() to split work:
// ❌ BAD: Long task (300ms)
items.forEach(item => processItem(item));
// ✅ GOOD: Break into chunks
for (const item of items) {
processItem(item);
await scheduler.yield(); // Let browser breathe
}Impact: 50-80% TBT reduction
---
Strategy 4: Use requestIdleCallback
Defer non-critical work:
// Critical: Render UI immediately
renderUI();
// Non-critical: Process in idle time
requestIdleCallback(() => {
processAnalytics();
});Impact: 30-60% TBT reduction
---
TBT Debugging Checklist
- [ ] Code split by route
- [ ] Defer non-critical JavaScript
- [ ] Break up long tasks (>50ms) with scheduler.yield()
- [ ] Use requestIdleCallback for non-critical work
- [ ] Tree shake unused code
- [ ] Verify TBT <200ms in Lighthouse
---
Tools for Debugging Core Web Vitals
Chrome DevTools
Performance Panel:
- Record page load
- Identify long tasks (>50ms)
- See layout shifts
- Analyze rendering bottlenecks
Lighthouse:
- Run performance audit
- Get specific recommendations
- See metric scores and weights
Coverage Tool:
- Identify unused CSS/JS
- Find code splitting opportunities
Web-Vitals Library
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id
});
navigator.sendBeacon('/analytics', body);
}
// Measure all Core Web Vitals
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onTTFB(sendToAnalytics);WebPageTest
Features:
- Filmstrip view
- Waterfall chart
- Connection view
- Test from multiple locations
URL: https://www.webpagetest.org
PageSpeed Insights
Features:
- Real user data (CrUX)
- Lab data (Lighthouse)
- Field data comparison
- Mobile vs desktop
URL: https://pagespeed.web.dev
---
Summary: Core Web Vitals Targets
| Metric | Good | Needs Improvement | Poor | Fix Priority |
|---|---|---|---|---|
| LCP | ≤2.5s | 2.5-4.0s | >4.0s | HIGH |
| INP | ≤200ms | 200-500ms | >500ms | MEDIUM |
| CLS | ≤0.1 | 0.1-0.25 | >0.25 | HIGH |
| TTFB | ≤800ms | 800-1800ms | >1800ms | CRITICAL |
| FCP | ≤1.8s | 1.8-3.0s | >3.0s | MEDIUM |
| TBT | ≤200ms | 200-600ms | >600ms | HIGH |
Optimization Priority: 1. TTFB (foundation for everything) 2. CLS (SEO + UX impact) 3. LCP (depends on TTFB) 4. TBT (highest Lighthouse weight) 5. INP (responsiveness) 6. FCP (perceived performance)
Next Steps:
- See optimization-techniques.md for detailed implementation
- See monitoring.md for continuous monitoring setup
- See quick-wins.md for time-boxed optimizations
---
Remember: Core Web Vitals are about user experience, not just scores. Focus on real-world impact, not gaming the metrics.
Framework-Specific Performance Patterns
Optimization strategies tailored to popular web frameworks and build tools.
---
Table of Contents
1. Next.js Optimization 2. React Optimization 3. Vue Optimization 4. Vite Optimization 5. Astro Optimization 6. SvelteKit Optimization
---
Next.js Optimization
Version: Next.js 14+ (App Router and Pages Router)
Image Optimization
Next.js Image component provides automatic optimization:
import Image from 'next/image';
// ❌ BAD: Standard img tag
<img src="/hero.jpg" alt="Hero" />
// ✅ GOOD: Next.js Image component
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Preload LCP image
/>
// ✅ BETTER: Responsive images
<Image
src="/hero.jpg"
alt="Hero"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority
/>next.config.js optimization:
module.exports = {
images: {
formats: ['image/avif', 'image/webp'], // Modern formats
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 60,
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.example.com',
},
],
},
};Impact: Automatic WebP/AVIF, responsive sizes, lazy loading
---
Font Optimization
App Router (`app/layout.tsx`):
import { Inter, Roboto_Mono } from 'next/font/google';
// Load Google Fonts with automatic optimization
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Prevent FOIT
variable: '--font-inter',
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
);
}Custom fonts (local):
import localFont from 'next/font/local';
const myFont = localFont({
src: './fonts/my-font.woff2',
display: 'swap',
variable: '--font-custom',
});Impact: Zero CLS, automatic preloading, self-hosted fonts
---
Static Generation vs SSR vs ISR
Performance trade-offs:
| Strategy | TTFB | Build Time | Use Case |
|---|---|---|---|
| Static (SSG) | <100ms | Long | Blog posts, marketing pages |
| ISR | <200ms | Medium | E-commerce, dynamic content with caching |
| SSR | 500-2000ms | Fast | User-specific pages, real-time data |
| Client-side | <100ms (HTML) | Fast | SPAs, dashboards |
Static Generation (fastest TTFB):
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return <article>{post.content}</article>;
}ISR (balanced):
// Revalidate every 60 seconds
export const revalidate = 60;
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return <div>{product.name}</div>;
}SSR (dynamic data):
// Force dynamic rendering
export const dynamic = 'force-dynamic';
export default async function UserDashboard() {
const session = await getSession();
const userData = await getUserData(session.userId);
return <Dashboard data={userData} />;
}---
Route Prefetching
Next.js automatically prefetches routes on <Link>:
import Link from 'next/link';
// ✅ GOOD: Automatic prefetching
<Link href="/about" prefetch>About</Link>
// Disable prefetching for less important routes
<Link href="/legal" prefetch={false}>Legal</Link>
// Programmatic prefetching
import { useRouter } from 'next/navigation';
const router = useRouter();
router.prefetch('/products');Impact: Instant navigation for prefetched routes
---
Bundle Analysis
# Install bundle analyzer
npm install --save-dev @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// ... config
});
# Run analysis
ANALYZE=true npm run build---
Edge Runtime (App Router)
Deploy performance-critical routes to edge:
// app/api/data/route.ts
export const runtime = 'edge'; // Run on Edge, not Node.js
export async function GET() {
const data = await fetch('https://api.example.com/data');
return Response.json(data);
}Impact: 50-200ms faster TTFB (served from edge locations)
---
Caching Strategies
App Router caching:
// Aggressive caching (static data)
const data = await fetch('https://api.example.com/static', {
cache: 'force-cache',
});
// Revalidate every 60 seconds
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 60 },
});
// No caching (always fresh)
const data = await fetch('https://api.example.com/user', {
cache: 'no-store',
});---
React Optimization
Component Memoization
Prevent unnecessary re-renders:
import { memo, useMemo, useCallback } from 'react';
// ❌ BAD: Re-renders on every parent update
function ExpensiveComponent({ data }) {
const processed = processData(data); // Expensive computation runs every render
return <div>{processed}</div>;
}
// ✅ GOOD: Memoize component
const ExpensiveComponent = memo(function ExpensiveComponent({ data }) {
const processed = useMemo(() => processData(data), [data]);
return <div>{processed}</div>;
});
// ✅ GOOD: Memoize callbacks
function Parent() {
const [count, setCount] = useState(0);
// ❌ BAD: New function every render
const handleClick = () => setCount(count + 1);
// ✅ GOOD: Memoized callback
const handleClick = useCallback(() => setCount(c => c + 1), []);
return <Child onClick={handleClick} />;
}When to memoize:
- Components that render frequently with same props
- Expensive computations
- Callbacks passed to memoized child components
When NOT to memoize:
- Simple components (overhead not worth it)
- Props change frequently
- Premature optimization
---
Code Splitting and Lazy Loading
import { lazy, Suspense } from 'react';
// ❌ BAD: Load everything upfront
import HeavyChart from './HeavyChart';
import Dashboard from './Dashboard';
// ✅ GOOD: Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart'));
const Dashboard = lazy(() => import('./Dashboard'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Dashboard />
<HeavyChart />
</Suspense>
);
}Route-based code splitting:
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));
const Contact = lazy(() => import('./routes/Contact'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}Impact: 30-50% smaller initial bundle
---
Virtual Scrolling
For long lists (1000+ items):
import { FixedSizeList } from 'react-window';
// ❌ BAD: Render all 10,000 items (slow!)
function List({ items }) {
return (
<div>
{items.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
// ✅ GOOD: Virtual scrolling (only render visible items)
function VirtualizedList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>{items[index].name}</div>
);
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}Impact: 10-100x faster for long lists
---
React Profiler
Identify performance bottlenecks:
import { Profiler } from 'react';
function onRenderCallback(
id,
phase, // "mount" or "update"
actualDuration, // Time spent rendering
baseDuration, // Estimated time without memoization
startTime,
commitTime
) {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
}
<Profiler id="Dashboard" onRender={onRenderCallback}>
<Dashboard />
</Profiler>---
Vue Optimization
Component Lazy Loading
<script setup>
import { defineAsyncComponent } from 'vue';
// ❌ BAD: Load everything upfront
import HeavyChart from './HeavyChart.vue';
// ✅ GOOD: Lazy load heavy components
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'));
</script>
<template>
<Suspense>
<HeavyChart />
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>---
Computed Properties vs Methods
<script setup>
import { ref, computed } from 'vue';
const items = ref([1, 2, 3, 4, 5]);
// ❌ BAD: Method runs on every render
function filteredItems() {
return items.value.filter(i => i > 2);
}
// ✅ GOOD: Computed property cached
const filteredItems = computed(() => {
return items.value.filter(i => i > 2);
});
</script>---
v-once for Static Content
<template>
<!-- Render once, never update -->
<div v-once>
<h1>{{ staticTitle }}</h1>
<p>{{ staticContent }}</p>
</div>
<!-- Render once per item -->
<div v-for="item in items" :key="item.id">
<div v-once>{{ item.staticData }}</div>
<div>{{ item.dynamicData }}</div>
</div>
</template>Impact: 50-80% faster re-renders for static content
---
Virtual Scrolling (Vue)
<script setup>
import { VirtualScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
const items = ref([...Array(10000).keys()]);
</script>
<template>
<VirtualScroller
:items="items"
:item-size="50"
class="scroller"
>
<template #default="{ item }">
<div class="item">{{ item }}</div>
</template>
</VirtualScroller>
</template>---
Vite Optimization
Build Optimization
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
build: {
// Chunk splitting strategy
rollupOptions: {
output: {
manualChunks: {
// Vendor chunk
vendor: ['react', 'react-dom'],
// UI library chunk
ui: ['@mui/material'],
},
},
},
// Production optimizations
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // Remove console.log
drop_debugger: true,
},
},
// Source map (disable in production)
sourcemap: false,
// Chunk size warnings
chunkSizeWarningLimit: 500,
},
// CSS code splitting
css: {
devSourcemap: false,
},
});---
Legacy Browser Support
// vite.config.js
import legacy from '@vitejs/plugin-legacy';
export default defineConfig({
plugins: [
legacy({
targets: ['defaults', 'not IE 11'],
modernPolyfills: true,
renderLegacyChunks: false,
}),
],
});---
Preview Mode (Production Testing)
# Build for production
npm run build
# Preview production build locally
npm run preview---
Astro Optimization
Partial Hydration (Component Islands)
Astro's killer feature: Zero JS by default
---
// src/pages/index.astro
import HeavyInteractive from '../components/HeavyInteractive.jsx';
import StaticComponent from '../components/StaticComponent.astro';
---
<html>
<body>
<!-- Static component: Zero JS shipped -->
<StaticComponent />
<!-- Interactive component: Only load when visible -->
<HeavyInteractive client:visible />
<!-- Interactive component: Load immediately -->
<ChatWidget client:load />
<!-- Interactive component: Load when idle -->
<Analytics client:idle />
</body>
</html>Client directives:
client:load- Load immediately (critical interactivity)client:idle- Load when browser idle (non-critical)client:visible- Load when scrolled into view (below fold)client:media- Load based on media queryclient:only- Only render on client (skip SSR)
Impact: 90-99% less JavaScript shipped
---
Zero-JS Pages
---
// Completely static page - ZERO JavaScript
const posts = await getPosts();
---
<html>
<head>
<title>Blog</title>
</head>
<body>
{posts.map(post => (
<article>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</body>
</html>Impact: LCP <1s, perfect Lighthouse scores
---
Build Optimization
// astro.config.mjs
export default defineConfig({
output: 'static', // or 'server' for SSR
build: {
inlineStylesheets: 'auto',
},
vite: {
build: {
cssCodeSplit: true,
},
},
});---
SvelteKit Optimization
Prerendering (SSG)
// src/routes/blog/[slug]/+page.js
export const prerender = true;
export async function load({ params }) {
const post = await getPost(params.slug);
return { post };
}---
Server-Side Rendering
// src/routes/+page.server.js
export async function load() {
const data = await fetchData();
return { data };
}---
Hydration Strategies
<!-- +page.svelte -->
<script>
// Only runs on client after hydration
import { browser } from '$app/environment';
if (browser) {
// Client-only code
initializeAnalytics();
}
</script>---
Build Optimization
// svelte.config.js
import adapter from '@sveltejs/adapter-auto';
export default {
kit: {
adapter: adapter(),
prerender: {
crawl: true,
entries: ['*'],
},
},
compilerOptions: {
immutable: true, // Performance optimization
},
};---
Framework Comparison
| Framework | Bundle Size | TTFB | Hydration | Best For |
|---|---|---|---|---|
| Next.js | Medium | Fast (SSG/ISR) | Full | Full-stack apps, SEO |
| React | Large | Slow (CSR) | Full | SPAs, dashboards |
| Vue | Medium | Medium | Full | Progressive enhancement |
| Vite | Small | Fast | Full | Modern build tool |
| Astro | Tiny | Very Fast | Partial | Content sites, blogs |
| SvelteKit | Small | Fast | Full | High-performance apps |
---
Framework-Agnostic Tips
Bundle Size Budgets
{
"budgets": [
{
"type": "bundle",
"name": "main",
"baseline": "150kb",
"warning": "200kb",
"error": "250kb"
}
]
}---
Performance Monitoring
All frameworks can use web-vitals:
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
// Send to your analytics provider
console.log(metric.name, metric.value);
}
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onTTFB(sendToAnalytics);---
Summary Checklist
Next.js
- [ ] Use Image component with
priorityfor LCP images - [ ] Optimize fonts with
next/font - [ ] Choose appropriate rendering strategy (SSG/ISR/SSR)
- [ ] Enable bundle analyzer
- [ ] Use Edge runtime for API routes
React
- [ ] Memoize expensive components
- [ ] Code split by route
- [ ] Use virtual scrolling for long lists
- [ ] Profile with React DevTools
- [ ] Implement lazy loading
Vue
- [ ] Use computed properties instead of methods
- [ ] Lazy load heavy components
- [ ] Use
v-oncefor static content - [ ] Implement virtual scrolling
- [ ] Code split by route
Vite
- [ ] Configure chunk splitting
- [ ] Enable minification
- [ ] Disable source maps in production
- [ ] Use legacy plugin if needed
- [ ] Preview production builds locally
Astro
- [ ] Use partial hydration (client directives)
- [ ] Prefer zero-JS pages
- [ ] Prerender static routes
- [ ] Inline critical CSS
- [ ] Use component islands pattern
SvelteKit
- [ ] Prerender static routes
- [ ] Use server-side data loading
- [ ] Optimize hydration strategy
- [ ] Enable compiler optimizations
- [ ] Configure adapter for deployment
---
Next: See core-web-vitals.md for metric-specific optimization strategies.
Modern Performance Patterns (2025)
Cutting-edge browser APIs and patterns for optimal web performance in 2025.
View Transitions API (2024+)
Purpose: Smooth page transitions without JavaScript framework overhead
Browser support: Chrome 111+, Edge 111+ (March 2023+)
Basic Implementation
/* Enable automatic view transitions for navigation */
@view-transition {
navigation: auto;
}That's it! This single line enables smooth cross-document transitions.
How It Works
1. Browser captures current page state (screenshot) 2. Navigate to new page 3. Browser animates between states 4. Zero JavaScript required
Impact:
- Perceived performance boost (feels instant)
- No runtime JavaScript cost
- Native browser optimization
- Works across page navigations
Custom Animations
/* Customize transition animations */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}
/* Slide animation */
::view-transition-old(root) {
animation-name: slide-out-to-left;
}
::view-transition-new(root) {
animation-name: slide-in-from-right;
}
@keyframes slide-out-to-left {
to {
transform: translateX(-100%);
}
}
@keyframes slide-in-from-right {
from {
transform: translateX(100%);
}
}Element-Specific Transitions
/* Target specific elements */
.card {
view-transition-name: card-transition;
}
::view-transition-old(card-transition),
::view-transition-new(card-transition) {
animation-duration: 0.5s;
animation-timing-function: ease-in-out;
}JavaScript Control (Optional)
// Programmatic control
document.startViewTransition(() => {
// Update DOM here
updateContent();
});
// With promise
async function navigate() {
const transition = document.startViewTransition(() => {
// DOM updates
});
await transition.finished;
console.log('Transition complete');
}Use Cases
- Multi-page applications (MPA) - Native smooth navigation
- SPA-like experience - Without SPA complexity
- Image galleries - Smooth image transitions
- Product pages - Seamless category navigation
Performance Characteristics
- Runtime cost: ~0ms (native browser feature)
- Compatibility: Progressive enhancement (graceful degradation)
- Network impact: None (purely visual)
---
Speculation Rules API (Chrome 121+, December 2023)
Purpose: Prerender pages for instant navigation
Browser support: Chrome 121+, Edge 121+
Prefetch vs Prerender Decision Tree
Is the page likely to be visited?
├─ YES, very likely (>50% probability)
│ └─ Use PRERENDER (full page load in background)
└─ NO, maybe (20-50% probability)
└─ Use PREFETCH (fetch resources only)Basic Prefetch
<script type="speculationrules">
{
"prefetch": [
{
"urls": ["/about", "/contact"]
}
]
}
</script>What happens: Browser fetches HTML, CSS, JS (no execution)
Prerender for Instant Navigation
<script type="speculationrules">
{
"prerender": [
{
"urls": ["/product/123"]
}
]
}
</script>What happens: Full page render in background, instant show on navigation
Impact: 0ms navigation time (feels instant)
Dynamic Rules Based on User Behavior
<script type="speculationrules">
{
"prerender": [
{
"where": {
"and": [
{"href_matches": "/products/*"},
{"selector_matches": "a:hover"}
]
}
}
]
}
</script>This prerenders product pages when user hovers over links!
Budget Constraints
// Set limits to avoid resource waste
{
"prerender": [
{
"urls": ["/important-page"],
"requires": ["anonymous-client-ip-when-cross-origin"]
}
],
"prefetch": [
{
"urls": ["/maybe-visited"],
"eagerness": "moderate" // conservative | moderate | eager
}
]
}Eagerness Levels
| Level | When Triggered | Use Case |
|---|---|---|
immediate | On page load | Critical next page |
eager | Link visible in viewport | High probability |
moderate | Mouse pointer near link | Medium probability |
conservative | Mouse/touch down on link | Low resource use |
Measuring Impact
// Track prerender success
document.addEventListener('prerenderingchange', () => {
if (document.prerendering) {
console.log('Page is prerendering');
} else {
console.log('Page activated from prerender');
// Track instant navigation
analytics.track('instant_navigation');
}
});
// Check if page was prerendered
if (document.wasPrerendered) {
console.log('This page was prerendered');
}Performance Characteristics
- 0ms navigation for prerendered pages
- CPU cost during prerender (user not waiting)
- Memory cost per prerendered page
- Network cost if page not visited
Best Practices
1. Prerender high-probability pages only (>50% visit rate) 2. Prefetch medium-probability pages (20-50% visit rate) 3. Monitor resource usage (CPU, memory, network) 4. Respect user data preferences (check navigator.connection) 5. Consider Analytics impact (prerendered pages trigger page views)
---
React Server Components (RSC)
Purpose: Zero-bundle server-only components
Availability: Next.js 13+ (App Router), React 19+
The Problem
Traditional React: All components ship to browser
// Traditional Client Component - 100KB bundle
import { Chart } from 'heavy-charting-library'; // 80KB
export default function Dashboard() {
const data = fetchData(); // This runs client-side
return <Chart data={data} />;
}Bundle includes: React + Chart library + your code = 100KB+
The Solution: Server Components
// Server Component - 0KB JavaScript!
import { Chart } from 'heavy-charting-library'; // Runs server-side only
export default async function Dashboard() {
const data = await fetchData(); // Server-side data fetching
return <Chart data={data} />; // Rendered to HTML
}Bundle: 0KB JavaScript (Chart library never sent to client)
When to Use Each
// app/layout.tsx - Server Component (default)
export default function Layout({ children }) {
return (
<html>
<body>
<Header /> {/* Server Component */}
{children}
<Footer /> {/* Server Component */}
</body>
</html>
);
}
// app/interactive-widget.tsx - Client Component
'use client'; // Opt into client rendering
import { useState } from 'react';
export default function InteractiveWidget() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}Decision Tree
Does component need:
- Browser APIs (window, localStorage)?
- Event handlers (onClick, onChange)?
- React hooks (useState, useEffect)?
- Third-party interactive libraries?
├─ YES → Client Component ('use client')
└─ NO → Server Component (default, zero JS)Bundle Impact Analysis
Before (All Client Components):
- React core: 45KB
- Component libraries: 80KB
- Application code: 120KB
- Total: 245KB
After (Server Components + Selective Client):
- React core: 45KB
- Interactive widgets only: 20KB
- Application code (client): 30KB
- Total: 95KB (61% reduction)
Streaming SSR with Suspense
import { Suspense } from 'react';
export default function Page() {
return (
<>
<Header /> {/* Renders immediately */}
<Suspense fallback={<Skeleton />}>
<SlowComponent /> {/* Streams in when ready */}
</Suspense>
<Footer /> {/* Renders immediately */}
</>
);
}Performance gain: Time to First Byte unaffected, progressive rendering
---
Priority Hints API
Purpose: Control resource loading priority
Browser support: Chrome 101+, Edge 101+, Safari 17.2+
fetchpriority Attribute
<!-- ❌ BAD: All images same priority -->
<img src="hero.jpg" alt="Hero">
<img src="thumbnail-1.jpg" alt="Thumbnail">
<img src="thumbnail-2.jpg" alt="Thumbnail">
<!-- ✅ GOOD: Prioritize LCP image -->
<img src="hero.jpg" alt="Hero" fetchpriority="high">
<img src="thumbnail-1.jpg" alt="Thumbnail" fetchpriority="low">
<img src="thumbnail-2.jpg" alt="Thumbnail" fetchpriority="low">Impact on LCP: 200-400ms improvement
Script Prioritization
<!-- High priority: Critical scripts -->
<script src="critical.js" fetchpriority="high"></script>
<!-- Low priority: Analytics, ads -->
<script src="analytics.js" fetchpriority="low" async></script>CSS Prioritization
<!-- High priority: Critical styles -->
<link rel="stylesheet" href="critical.css" fetchpriority="high">
<!-- Low priority: Print styles -->
<link rel="stylesheet" href="print.css" fetchpriority="low" media="print">Preload with Priority
<!-- High priority preload for LCP image -->
<link rel="preload" as="image" href="hero.webp" fetchpriority="high">
<!-- Low priority preload for below-fold -->
<link rel="preload" as="image" href="footer-logo.webp" fetchpriority="low">---
blocking="render" for Critical CSS
Purpose: Block rendering until critical CSS loads (better than inline)
Browser support: Chrome 105+, Firefox 120+
<!-- ❌ OLD: Inline critical CSS -->
<style>
/* Thousands of lines of CSS... */
</style>
<!-- ✅ NEW: External critical CSS with render blocking -->
<link rel="stylesheet" href="critical.css" blocking="render">Benefits:
- Cacheable (unlike inline CSS)
- Smaller HTML
- Still blocks render (prevents FOUC)
---
content-visibility for Rendering Optimization
Purpose: Skip rendering off-screen content
Browser support: Chrome 85+, Edge 85+
Basic Pattern
.article-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Estimated height */
}Impact: 7x faster rendering for long pages
How It Works
1. Browser skips rendering off-screen sections 2. Reserves space (contain-intrinsic-size) to prevent layout shifts 3. Renders sections as they enter viewport
Use Cases
/* Long list of items */
.list-item {
content-visibility: auto;
contain-intrinsic-size: 0 200px;
}
/* Blog articles with many sections */
.blog-section {
content-visibility: auto;
contain-intrinsic-size: 0 600px;
}
/* Product grid */
.product-card {
content-visibility: auto;
contain-intrinsic-size: 0 400px;
}Performance Metrics
Without content-visibility:
- Initial render: 450ms
- Layout time: 280ms
With content-visibility:
- Initial render: 65ms (85% faster)
- Layout time: 40ms (86% faster)
---
Islands Architecture
Purpose: Selective hydration for minimal JavaScript
Frameworks: Astro, Qwik, Fresh
The Problem
Traditional SSR: Hydrate entire page (all components become interactive)
// Traditional: Everything hydrates (500KB JS)
<Header /> {/* Hydrates (static, why?) */}
<Hero /> {/* Hydrates (static, why?) */}
<InteractiveForm /> {/* Needs hydration */}
<Footer /> {/* Hydrates (static, why?) */}Total JavaScript: 500KB
The Solution: Islands
---
import Header from './Header.astro'; // Static
import Hero from './Hero.astro'; // Static
import Form from './Form.jsx'; // Interactive island
import Footer from './Footer.astro'; // Static
---
<Header />
<Hero />
<Form client:visible /> {/* Only this hydrates! */}
<Footer />Total JavaScript: 20KB (96% reduction)
Hydration Strategies
<!-- Load immediately -->
<Component client:load />
<!-- Load when idle (after page interactive) -->
<Component client:idle />
<!-- Load when visible in viewport -->
<Component client:visible />
<!-- Load on media query match -->
<Component client:media="(max-width: 768px)" />
<!-- Never hydrate (static HTML only) -->
<Component />Performance Comparison
| Approach | Initial JS | TTI | Use Case |
|---|---|---|---|
| Full SPA | 500KB | 3.5s | Highly interactive apps |
| Traditional SSR | 500KB | 2.8s | Interactive pages |
| Partial Hydration | 150KB | 1.2s | Mixed static/interactive |
| Islands | 20KB | 0.3s | Mostly static content |
Real-World Example: Blog
---
// Blog post page - 98% static content
---
<html>
<StaticHeader />
<StaticNav />
<article>
<StaticBlogContent />
<!-- Only interactive parts hydrate -->
<LikeButton client:visible />
<CommentSection client:idle />
<ShareButtons client:visible />
</article>
<StaticFooter />
</html>Result:
- Before: 450KB JavaScript
- After: 35KB JavaScript (92% reduction)
- TTI: 3.2s → 0.4s
---
Summary: 2025 Performance Stack
| Pattern | Browser Support | Impact | Complexity |
|---|---|---|---|
| View Transitions | Chrome 111+ | High (UX) | Low |
| Speculation Rules | Chrome 121+ | Extreme (0ms nav) | Low |
| Server Components | Next.js 13+, React 19+ | Very High (bundle) | Medium |
| Priority Hints | Chrome 101+, Safari 17.2+ | High (LCP) | Low |
| blocking="render" | Chrome 105+, Firefox 120+ | Medium (FOUC) | Low |
| content-visibility | Chrome 85+ | High (render) | Low |
| Islands | Framework-dependent | Extreme (bundle) | High |
Recommendation: Start with Priority Hints and View Transitions (low complexity, high impact). Progress to Speculation Rules and Server Components for maximum performance gains.
---
Remember: These patterns are progressive enhancements. Sites work without them, but users get dramatically better experience when supported.
Performance Monitoring
Comprehensive guide to measuring, tracking, and maintaining web performance over time.
---
Table of Contents
1. Lighthouse CI Setup 2. Real User Monitoring (RUM) 3. Backend Monitoring 4. Performance Budgets 5. Debugging Tools
---
Lighthouse CI Setup
Continuous performance testing in CI/CD pipeline
Installation
# Install Lighthouse CI
npm install --save-dev @lhci/cli
# Initialize configuration
npx lhci initConfiguration
lighthouserc.json:
{
"ci": {
"collect": {
"numberOfRuns": 3,
"startServerCommand": "npm run serve",
"url": [
"http://localhost:3000/",
"http://localhost:3000/about",
"http://localhost:3000/products"
]
},
"assert": {
"assertions": {
"categories:performance": ["error", {"minScore": 0.9}],
"categories:accessibility": ["warn", {"minScore": 0.9}],
"first-contentful-paint": ["error", {"maxNumericValue": 2000}],
"largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
"cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}],
"total-blocking-time": ["error", {"maxNumericValue": 300}],
"interactive": ["error", {"maxNumericValue": 3500}]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}GitHub Actions Integration
.github/workflows/lighthouse.yml:
name: Lighthouse CI
on: [push, pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v10
with:
urls: |
http://localhost:3000
http://localhost:3000/about
uploadArtifacts: true
temporaryPublicStorage: true
- name: Comment PR with results
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
// Post Lighthouse results as PR commentGitLab CI Integration
.gitlab-ci.yml:
lighthouse:
image: node:18
stage: test
script:
- npm ci
- npm run build
- npm install -g @lhci/cli@0.12.x
- lhci autorun --collect.numberOfRuns=3
artifacts:
paths:
- .lighthouseci/
expire_in: 1 weekLocal Usage
# Run Lighthouse CI locally
npx lhci autorun
# Run specific URL
npx lhci collect --url=http://localhost:3000
# Assert against budgets
npx lhci assertImpact: Catch performance regressions before deployment
---
Real User Monitoring (RUM)
Measure actual user experience in production
web-vitals Library Setup
npm install web-vitalsImplementation
// analytics.js
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
// Custom context
path: window.location.pathname,
userAgent: navigator.userAgent,
connectionType: navigator.connection?.effectiveType,
});
// Send via Beacon API (survives page unload)
if (navigator.sendBeacon) {
navigator.sendBeacon('/analytics', body);
} else {
// Fallback to fetch
fetch('/analytics', {
body,
method: 'POST',
keepalive: true,
});
}
}
// Measure all Core Web Vitals
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onTTFB(sendToAnalytics);Analytics Integration
Google Analytics 4:
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
function sendToGoogleAnalytics(metric) {
gtag('event', metric.name, {
event_category: 'Web Vitals',
value: Math.round(metric.value),
event_label: metric.id,
non_interaction: true,
});
}
onCLS(sendToGoogleAnalytics);
onFCP(sendToGoogleAnalytics);
onINP(sendToGoogleAnalytics);
onLCP(sendToGoogleAnalytics);
onTTFB(sendToGoogleAnalytics);Vercel Analytics:
import { Analytics } from '@vercel/analytics/react';
export default function App() {
return (
<>
<YourApp />
<Analytics /> {/* Automatic Web Vitals tracking */}
</>
);
}Custom Backend:
// server.js (Express)
app.post('/analytics', express.json(), (req, res) => {
const { name, value, rating, path, userAgent } = req.body;
// Store in database
db.metrics.insert({
metric_name: name,
value: value,
rating: rating,
page_path: path,
user_agent: userAgent,
timestamp: new Date(),
});
res.sendStatus(200);
});Attribution Data
Get detailed context about metrics:
import { onLCP } from 'web-vitals/attribution';
onLCP((metric) => {
console.log('LCP:', metric.value);
console.log('Attribution:', metric.attribution);
console.log('LCP element:', metric.attribution.element);
console.log('LCP resource URL:', metric.attribution.url);
console.log('TTFB:', metric.attribution.timeToFirstByte);
console.log('Resource load time:', metric.attribution.resourceLoadTime);
console.log('Render time:', metric.attribution.renderTime);
});Impact: Understand root causes of poor performance
---
Backend Monitoring
Monitor server-side performance (critical for TTFB)
Application Performance Monitoring (APM)
New Relic:
// newrelic.js
'use strict';
exports.config = {
app_name: ['Your App'],
license_key: 'your_license_key',
logging: {
level: 'info',
},
distributed_tracing: {
enabled: true,
},
};
// app.js
require('newrelic');
const express = require('express');
// ... rest of your appDatadog:
// tracer.js
const tracer = require('dd-trace').init({
logInjection: true,
analytics: true,
});
// app.js
require('./tracer');
const express = require('express');
// ... rest of your appSentry Performance:
const Sentry = require('@sentry/node');
Sentry.init({
dsn: 'your_dsn',
tracesSampleRate: 0.1, // Sample 10% of transactions
integrations: [
new Sentry.Integrations.Http({ tracing: true }),
new Sentry.Integrations.Express({ app }),
],
});
app.use(Sentry.Handlers.requestHandler());
app.use(Sentry.Handlers.tracingHandler());
// ... routes
app.use(Sentry.Handlers.errorHandler());---
Database Query Monitoring
PostgreSQL slow query log:
-- Enable slow query log (queries >100ms)
ALTER SYSTEM SET log_min_duration_statement = 100;
SELECT pg_reload_conf();
-- View slow queries
SELECT
query,
mean_exec_time,
calls,
total_exec_time
FROM pg_stat_statements
WHERE mean_exec_time > 100
ORDER BY mean_exec_time DESC
LIMIT 10;MySQL slow query log:
-- Enable slow query log
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.1; -- 100ms
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow-query.log';
-- Analyze slow queries
SELECT * FROM mysql.slow_log
WHERE query_time > 0.1
ORDER BY query_time DESC
LIMIT 10;---
Cache Monitoring
Redis monitoring:
# Monitor cache hit/miss ratio
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
# Real-time monitoring
redis-cli MONITORApplication-level cache monitoring:
// cache-monitor.js
class CacheMonitor {
constructor() {
this.hits = 0;
this.misses = 0;
}
recordHit() {
this.hits++;
}
recordMiss() {
this.misses++;
}
getHitRate() {
const total = this.hits + this.misses;
return total > 0 ? (this.hits / total) * 100 : 0;
}
getStats() {
return {
hits: this.hits,
misses: this.misses,
hitRate: this.getHitRate(),
};
}
}
const cacheMonitor = new CacheMonitor();
function getFromCache(key) {
const cached = redis.get(key);
if (cached) {
cacheMonitor.recordHit();
return cached;
}
cacheMonitor.recordMiss();
return null;
}
// Expose metrics endpoint
app.get('/metrics/cache', (req, res) => {
res.json(cacheMonitor.getStats());
});---
Server Response Time Tracking
Express middleware:
const responseTime = require('response-time');
app.use(responseTime((req, res, time) => {
// Log slow requests (>1000ms)
if (time > 1000) {
console.log({
method: req.method,
url: req.url,
responseTime: time,
timestamp: new Date(),
});
}
// Send to metrics backend
metrics.record('http_request_duration', time, {
method: req.method,
path: req.path,
status: res.statusCode,
});
}));---
Infrastructure Monitoring
CloudWatch (AWS):
const AWS = require('aws-sdk');
const cloudwatch = new AWS.CloudWatch();
function publishMetric(metricName, value) {
const params = {
Namespace: 'YourApp',
MetricData: [
{
MetricName: metricName,
Value: value,
Unit: 'Milliseconds',
Timestamp: new Date(),
},
],
};
cloudwatch.putMetricData(params, (err, data) => {
if (err) console.error(err);
});
}
// Track TTFB
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
publishMetric('ResponseTime', duration);
});
next();
});Google Cloud Monitoring:
const { Monitoring } = require('@google-cloud/monitoring');
const client = new Monitoring.MetricServiceClient();
async function writeMetric(metricType, value) {
const projectId = 'your-project-id';
const dataPoint = {
interval: {
endTime: {
seconds: Date.now() / 1000,
},
},
value: {
doubleValue: value,
},
};
const timeSeriesData = {
metric: {
type: `custom.googleapis.com/${metricType}`,
},
resource: {
type: 'global',
},
points: [dataPoint],
};
const request = {
name: client.projectPath(projectId),
timeSeries: [timeSeriesData],
};
await client.createTimeSeries(request);
}---
Performance Budgets
Define and enforce performance limits
webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
# package.json
{
"scripts": {
"analyze": "webpack-bundle-analyzer build/stats.json"
}
}Performance Budget Configuration
webpack.config.js:
module.exports = {
performance: {
maxAssetSize: 250000, // 250KB
maxEntrypointSize: 250000,
hints: 'error', // Fail build if exceeded
},
};lighthouserc.json budgets:
{
"ci": {
"assert": {
"budgets": [
{
"path": "/*",
"resourceSizes": [
{
"resourceType": "script",
"budget": 300
},
{
"resourceType": "stylesheet",
"budget": 50
},
{
"resourceType": "image",
"budget": 500
},
{
"resourceType": "total",
"budget": 1000
}
],
"timings": [
{
"metric": "first-contentful-paint",
"budget": 2000
},
{
"metric": "largest-contentful-paint",
"budget": 2500
},
{
"metric": "interactive",
"budget": 3500
}
]
}
]
}
}
}---
Debugging Tools
Chrome DevTools
Performance Panel: 1. Open DevTools (F12) 2. Go to Performance panel 3. Click Record (Cmd+E) 4. Reload page 5. Stop recording 6. Analyze timeline
What to look for:
- Long tasks (>50ms) in Main section
- Layout shifts in Experience section
- Network waterfall
- JavaScript execution time
Coverage Tool: 1. Open DevTools 2. Cmd+Shift+P → "Show Coverage" 3. Click Record 4. Reload page 5. See unused CSS/JS (red = unused)
---
WebPageTest
URL: https://www.webpagetest.org
Features:
- Test from multiple locations
- Connection speed simulation (3G, 4G, Cable)
- Filmstrip view
- Waterfall chart
- Video comparison
- Advanced metrics
API usage:
# Run test via API
curl "https://www.webpagetest.org/runtest.php?url=https://example.com&k=YOUR_API_KEY&f=json"---
PageSpeed Insights
URL: https://pagespeed.web.dev
Features:
- Real user data (CrUX - Chrome User Experience Report)
- Lab data (Lighthouse)
- Field vs lab comparison
- Mobile vs desktop
- Core Web Vitals status
API usage:
const fetch = require('node-fetch');
async function getPageSpeedInsights(url) {
const apiKey = 'YOUR_API_KEY';
const apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&key=${apiKey}`;
const response = await fetch(apiUrl);
const data = await response.json();
return {
fcp: data.lighthouseResult.audits['first-contentful-paint'].numericValue,
lcp: data.lighthouseResult.audits['largest-contentful-paint'].numericValue,
cls: data.lighthouseResult.audits['cumulative-layout-shift'].numericValue,
tbt: data.lighthouseResult.audits['total-blocking-time'].numericValue,
};
}---
Custom Performance Dashboard
Simple monitoring dashboard:
// dashboard.js - Backend
const express = require('express');
const app = express();
// Store metrics in memory (use Redis/DB in production)
const metrics = [];
app.post('/analytics', express.json(), (req, res) => {
metrics.push({
...req.body,
timestamp: new Date(),
});
res.sendStatus(200);
});
app.get('/dashboard', (req, res) => {
const last24h = metrics.filter(m =>
new Date(m.timestamp) > new Date(Date.now() - 24 * 60 * 60 * 1000)
);
const stats = {
lcp: calculateP75(last24h.filter(m => m.name === 'LCP')),
cls: calculateP75(last24h.filter(m => m.name === 'CLS')),
inp: calculateP75(last24h.filter(m => m.name === 'INP')),
};
res.json(stats);
});
function calculateP75(values) {
const sorted = values.map(v => v.value).sort((a, b) => a - b);
const index = Math.floor(sorted.length * 0.75);
return sorted[index] || 0;
}
app.listen(3000);---
Monitoring Checklist
CI/CD
- [ ] Lighthouse CI running on every PR
- [ ] Performance budgets enforced
- [ ] Bundle size tracked
- [ ] Regression alerts configured
Real User Monitoring
- [ ] web-vitals library integrated
- [ ] Core Web Vitals tracked in analytics
- [ ] Attribution data collected
- [ ] Slow page alerts configured
Backend Monitoring
- [ ] APM tool installed (New Relic, Datadog, Sentry)
- [ ] Slow query logging enabled
- [ ] Cache hit/miss tracking
- [ ] Server response time monitoring
- [ ] Infrastructure metrics (CPU, memory, disk)
Debugging
- [ ] Chrome DevTools performance traces collected
- [ ] WebPageTest used for detailed analysis
- [ ] PageSpeed Insights checked regularly
- [ ] Coverage tool used to identify unused code
Dashboards
- [ ] Performance dashboard created
- [ ] Metrics visualized (Grafana, Datadog, custom)
- [ ] Alerts configured for regressions
- [ ] Weekly performance reports automated
---
Target Metrics:
- TTFB: <800ms (backend monitoring critical)
- LCP: <2.5s (RUM + Lighthouse CI)
- CLS: <0.1 (RUM + Layout Shift tracking)
- INP: <200ms (RUM + interaction logging)
- Bundle Size: <250KB (webpack budgets)
- Cache Hit Ratio: >80% (Redis monitoring)
---
Next: See core-web-vitals.md for metric optimization strategies.
Quick Wins - Time-Boxed Performance Optimizations
Prioritized optimizations by time investment and impact. Start from the top and work down.
If You Have 1 Hour (High Impact, Low Effort)
1. Add loading="lazy" to Below-Fold Images
Impact: Reduces initial page weight by 40-60%
Implementation:
<!-- ❌ BAD: Loading all images eagerly -->
<img src="hero.jpg" alt="Hero">
<img src="content-1.jpg" alt="Content 1">
<img src="content-2.jpg" alt="Content 2">
<!-- ✅ GOOD: Lazy load below-fold images -->
<img src="hero.jpg" alt="Hero" loading="eager" fetchpriority="high">
<img src="content-1.jpg" alt="Content 1" loading="lazy">
<img src="content-2.jpg" alt="Content 2" loading="lazy">Browser support: 96% (all modern browsers)
Common pitfall: DO NOT lazy load the LCP image (largest image above fold)
---
2. Enable Compression (gzip/brotli)
Impact: Reduces transfer size by 70-80%
Implementation:
Nginx:
# /etc/nginx/nginx.conf
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml font/truetype font/opentype
application/vnd.ms-fontobject image/svg+xml;
# Brotli (if module installed)
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss;Apache:
# .htaccess
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
AddOutputFilterByType DEFLATE application/javascript application/json
</IfModule>Express.js:
const compression = require('compression');
app.use(compression());Common pitfall: Don't compress images/videos (already compressed)
---
3. Add rel="preconnect" for Critical Origins
Impact: Saves 100-500ms per critical resource
Implementation:
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://cdn.example.com">
<!-- For less critical origins, use dns-prefetch -->
<link rel="dns-prefetch" href="https://analytics.example.com">When to use:
- Fonts from Google Fonts or similar CDNs
- Critical assets from CDN
- API servers for initial data fetch
- Critical third-party services
When NOT to use:
- More than 3-4 origins (dilutes benefit)
- Non-critical resources
- Same-origin resources
---
4. Prevent CLS with Image Dimensions
Impact: Reduces CLS by 50-80% (prevents layout shifts)
Implementation:
<!-- ❌ BAD: No dimensions, causes layout shift -->
<img src="hero.jpg" alt="Hero">
<!-- Page loads → Text appears → Image loads → Text shifts down → CLS: 0.25+ -->
<!-- ✅ GOOD: Explicit dimensions prevent CLS -->
<img src="hero.jpg" alt="Hero" width="1200" height="600">
<!-- Browser reserves space before image loads → No shift! CLS: 0 -->
<!-- ✅ BETTER: Responsive with aspect-ratio CSS -->
<img src="hero.jpg" alt="Hero" style="aspect-ratio: 16/9; width: 100%;">CSS for all images:
img {
max-width: 100%;
height: auto;
/* aspect-ratio preserved from width/height attributes */
}
/* Or use aspect-ratio for responsive images */
.responsive-img {
aspect-ratio: 16/9;
width: 100%;
object-fit: cover;
}For background images:
.hero {
background: url('hero.jpg') center/cover;
aspect-ratio: 16/9;
/* Reserve space with aspect ratio */
}How to add dimensions to existing images:
# Get image dimensions
identify hero.jpg
# Output: hero.jpg JPEG 1200x600
# Add to HTML
<img src="hero.jpg" alt="Hero" width="1200" height="600">Common pitfall: Don't add dimensions to images you plan to lazy load, unless you're also adding loading="lazy" attribute
Browser support: 100% (width/height attributes), 94% (aspect-ratio CSS)
SEO Impact: CLS is a Core Web Vitals ranking factor - fixing this improves Google rankings
Verification: 1. Enable "Layout Shift Regions" in Chrome DevTools 2. Reload page 3. Verify no blue highlighting (= no shifts)
---
If You Have 1 Day (Medium Impact, Medium Effort)
5. Implement Code Splitting
Impact: Reduces initial bundle by 30-50%
React implementation:
// ❌ BAD: Loading everything upfront
import Dashboard from './Dashboard';
import Settings from './Settings';
import Reports from './Reports';
function App() {
return (
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
</Routes>
);
}
// ✅ GOOD: Route-based code splitting
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Reports = lazy(() => import('./Reports'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}Next.js (automatic):
// Next.js automatically code splits by route
// pages/dashboard.tsx
export default function Dashboard() {
return <div>Dashboard</div>;
}
// Dynamic import for components
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <p>Loading chart...</p>
});Verification:
- Use webpack-bundle-analyzer or Vite visualizer
- Check network tab for separate chunk files
- Measure bundle size reduction
---
6. Optimize LCP Image with fetchpriority="high"
Impact: Improves LCP by 200-400ms
Implementation:
<!-- ❌ BAD: No optimization -->
<img src="hero.jpg" alt="Hero">
<!-- ✅ GOOD: Optimized for LCP -->
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img
src="hero.jpg"
alt="Hero"
width="1200"
height="600"
fetchpriority="high"
loading="eager"
>
</picture>
<!-- Even better: Preload the LCP image -->
<head>
<link rel="preload" as="image" href="hero.webp" fetchpriority="high">
</head>Identifying your LCP image: 1. Run Lighthouse 2. Look for "Largest Contentful Paint element" 3. Optimize that specific image
Modern format conversion:
# Using ImageMagick
magick hero.jpg -quality 85 hero.webp
magick hero.jpg -quality 85 hero.avif
# Using cwebp/avifenc
cwebp -q 85 hero.jpg -o hero.webp
avifenc -s 5 hero.jpg hero.avif---
7. Add Basic Service Worker for Offline Support
Impact: Instant repeat visits, better perceived performance
Implementation:
// sw.js - Basic caching strategy
const CACHE_NAME = 'v1';
const urlsToCache = [
'/',
'/styles.css',
'/app.js',
'/logo.png'
];
// Install event: cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
// Fetch event: serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
// Activate event: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
})
);
});Register service worker:
// Register in your main app file
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered:', reg))
.catch(err => console.log('SW registration failed:', err));
});
}Using Workbox (recommended for production):
// Install Workbox
npm install --save-dev workbox-webpack-plugin
// webpack.config.js
const WorkboxPlugin = require('workbox-webpack-plugin');
module.exports = {
plugins: [
new WorkboxPlugin.GenerateSW({
clientsClaim: true,
skipWaiting: true,
})
]
};---
If You Have 1 Week (High Impact, High Effort)
8. Implement Full Caching Strategy
Impact: Very High - enables instant repeat visits, reduces server load
HTTP Cache Headers:
# Nginx configuration
location ~* \.(jpg|jpeg|png|gif|webp|avif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.(woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
}
location ~* \.(html)$ {
expires -1;
add_header Cache-Control "no-cache, must-revalidate";
add_header ETag "";
}
# API responses with stale-while-revalidate
location /api/ {
add_header Cache-Control "max-age=60, stale-while-revalidate=600";
}Service worker caching patterns:
// Advanced caching with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// Precache build assets
precacheAndRoute(self.__WB_MANIFEST);
// Cache images: CacheFirst
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
})
);
// Cache API: NetworkFirst
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5 minutes
}),
],
})
);
// Cache CSS/JS: StaleWhileRevalidate
registerRoute(
({ request }) =>
request.destination === 'script' ||
request.destination === 'style',
new StaleWhileRevalidate({
cacheName: 'static-resources',
})
);CDN caching configuration:
- Set long cache times for versioned assets
- Use cache tags for invalidation
- Implement cache warming for critical pages
---
9. Optimize Bundle Size with Tree Shaking
Impact: 40-60% bundle reduction
Implementation:
Enable tree shaking:
// package.json
{
"sideEffects": false // Enable aggressive tree shaking
}
// Or specify files with side effects
{
"sideEffects": ["*.css", "*.scss", "src/polyfills.js"]
}Import correctly:
// ❌ BAD: Imports entire library
import _ from 'lodash';
const result = _.debounce(fn, 300);
// ✅ GOOD: Import only what you need (ES modules)
import { debounce } from 'lodash-es';
const result = debounce(fn, 300);
// ✅ ALSO GOOD: Individual module import
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);Remove unused code:
# Install and run webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
npm run build -- --analyze
# Look for:
# - Large dependencies that are barely used
# - Duplicate dependencies
# - Unused exportsWebpack configuration:
// webpack.config.js
module.exports = {
mode: 'production', // Enables tree shaking
optimization: {
usedExports: true, // Mark unused exports
minimize: true, // Remove unused code
sideEffects: true, // Respect package.json sideEffects
}
};---
10. Add Performance Monitoring (Lighthouse CI + RUM)
Impact: Continuous visibility into performance regressions
Lighthouse CI setup:
# Install Lighthouse CI
npm install -D @lhci/cli
# Run locally
lhci autorun --collect.numberOfRuns=3lighthouserc.json:
{
"ci": {
"collect": {
"numberOfRuns": 3,
"startServerCommand": "npm run serve",
"url": ["http://localhost:3000/"]
},
"assert": {
"assertions": {
"categories:performance": ["error", {"minScore": 0.9}],
"first-contentful-paint": ["error", {"maxNumericValue": 2000}],
"largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
"cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}],
"total-blocking-time": ["error", {"maxNumericValue": 300}]
}
}
}
}GitHub Actions integration:
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push, pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v10
with:
urls: |
http://localhost:3000
http://localhost:3000/about
uploadArtifacts: trueReal User Monitoring (RUM):
// Install web-vitals
npm install web-vitals
// In your app
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
});
if (navigator.sendBeacon) {
navigator.sendBeacon('/analytics', body);
} else {
fetch('/analytics', { body, method: 'POST', keepalive: true });
}
}
// Measure all Core Web Vitals
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onTTFB(sendToAnalytics);---
Priority Matrix
| Optimization | Time | Impact | ROI | Difficulty |
|---|---|---|---|---|
| Lazy loading | 1h | High | ⭐⭐⭐⭐⭐ | Easy |
| Compression | 1h | High | ⭐⭐⭐⭐⭐ | Easy |
| Preconnect | 1h | Medium | ⭐⭐⭐⭐ | Easy |
| CLS prevention | 1h | Very High | ⭐⭐⭐⭐⭐ | Easy |
| Code splitting | 1d | High | ⭐⭐⭐⭐ | Medium |
| LCP optimization | 1d | High | ⭐⭐⭐⭐ | Medium |
| Service worker | 1d | Medium | ⭐⭐⭐ | Medium |
| Full caching | 1w | Very High | ⭐⭐⭐⭐⭐ | Hard |
| Bundle optimization | 1w | High | ⭐⭐⭐⭐ | Hard |
| Monitoring | 1w | Medium | ⭐⭐⭐ | Medium |
Verification Checklist
After implementing quick wins, verify improvements:
- [ ] Run Lighthouse before/after
- [ ] Check Lighthouse score improved by 10+ points
- [ ] LCP improved by 20%+ (target: <2.5s)
- [ ] INP improved by 20%+ (target: <200ms)
- [ ] CLS improved by 30%+ (target: <0.1)
- [ ] Total bundle size reduced by 20%+
- [ ] Page weight reduced by 30%+
- [ ] Time to Interactive improved by 25%+
---
Remember: Start with 1-hour optimizations for immediate wins. These give the best ROI and build momentum for larger optimizations.