| name | web-perf |
| description | Diagnose and fix web performance through a disciplined measure-first loop. Forces evidence-based optimization: measure → identify bottleneck → fix ONE thing → re-measure → repeat. Prevents guessing. Use when pages load slowly, user mentions performance, LCP, INP, CLS, lighthouse, bundle size, or "make it faster". |
Web Performance Optimization
Persistence
ACTIVE whenever performance is the goal. Never optimize without measuring first. Never declare "faster" without before/after numbers.
The Loop (never skip steps)
1. MEASURE — get baseline numbers (Lighthouse, bundle size, TTFB)
2. IDENTIFY — which metric is worst? (LCP? INP? CLS? TTFB?)
3. DIAGNOSE — what's causing THAT specific metric to be bad?
4. FIX ONE THING — smallest change that moves the needle
5. RE-MEASURE — did the number actually improve? By how much?
6. REPEAT — next worst metric. Never fix two things at once.
Why This Order Matters
Without measuring first: you optimize something that wasn't the bottleneck. Wasted work.
Without re-measuring: you "optimized" but might have made it worse (happens more than you think).
Fixing two things at once: you don't know which one helped (or hurt).
npx lighthouse https://your-site.com --output json --output-path ./lh-report.json
npx next build --analyze
npx vite-bundle-visualizer
npx webpack-bundle-analyzer stats.json
Core Web Vitals Targets
| Metric | Good | Needs Work | Poor | What It Measures |
|---|
| LCP | < 2.5s | 2.5-4.0s | > 4.0s | Largest visible element render time |
| INP | < 200ms | 200-500ms | > 500ms | Responsiveness to user input |
| CLS | < 0.1 | 0.1-0.25 | > 0.25 | Visual stability (layout shifts) |
Fix by Metric
LCP (Largest Contentful Paint)
Most common causes and fixes:
- Render-blocking resources
<link rel="stylesheet" href="styles.css">
<script src="app.js"></script>
<link rel="stylesheet" href="critical.css">
<link rel="stylesheet" href="styles.css" media="print" onload="this.media='all'">
<script src="app.js" defer></script>
- Unoptimized hero image
<img src="hero.png" width="1920" height="1080">
<link rel="preload" as="image" href="hero.webp" fetchpriority="high">
<img src="hero.webp" width="1920" height="1080"
srcset="hero-480.webp 480w, hero-960.webp 960w, hero-1920.webp 1920w"
sizes="100vw" alt="Hero" fetchpriority="high" decoding="async">
- Slow server response (TTFB)
Fixes:
- Enable CDN (Cloudflare, Vercel Edge, AWS CloudFront)
- Server-side caching (Redis, in-memory)
- Database query optimization (indexes, connection pooling)
- Reduce redirect chains
- Use HTTP/2 or HTTP/3
- Client-side rendering bottleneck
Fixes:
- SSR/SSG for above-fold content (Next.js, Nuxt, Astro)
- Stream HTML with React Suspense
- Inline critical CSS (< 14KB)
- Preconnect to required origins
INP (Interaction to Next Paint)
Most common causes and fixes:
- Long tasks blocking main thread
function handleClick() {
processLargeDataset(data);
updateUI();
}
async function handleClick() {
for (const chunk of chunks(data, 100)) {
processChunk(chunk);
await scheduler.yield();
}
updateUI();
}
- Heavy event handlers
input.addEventListener('input', () => renderList(filter(items)));
input.addEventListener('input', debounce(() => {
startTransition(() => renderList(filter(items)));
}, 150));
- JavaScript bundle too large
Fixes:
- Code split by route (dynamic import)
- Tree-shake unused code
- Replace heavy libraries (moment→dayjs, lodash→lodash-es)
- Defer non-critical JS
- Use web workers for computation
CLS (Cumulative Layout Shift)
Most common causes and fixes:
- Images without dimensions
<img src="photo.jpg">
<img src="photo.jpg" width="800" height="600">
<img src="photo.jpg" style="aspect-ratio: 4/3; width: 100%">
- Dynamic content insertion
.ad-slot { min-height: 250px; }
.embed-container { aspect-ratio: 16/9; }
- Web fonts causing FOUT
@font-face {
font-family: 'Custom';
src: url('font.woff2') format('woff2');
font-display: swap;
size-adjust: 100.5%;
ascent-override: 95%;
descent-override: 22%;
}
Optimization Checklist
Critical Path
Images
JavaScript
Caching
Fonts
Framework-Specific
Next.js
module.exports = {
images: { formats: ['image/avif', 'image/webp'] },
experimental: { optimizeCss: true },
compiler: { removeConsole: { exclude: ['error'] } }
};
Vite
export default {
build: {
rollupOptions: { output: { manualChunks: { vendor: ['react', 'react-dom'] } } },
cssCodeSplit: true,
minify: 'terser'
}
};
Common Mistakes
- Optimizing without measuring first (you might fix the wrong thing)
- Adding
loading="lazy" to above-fold images (hurts LCP)
- Excessive preloading (everything preloaded = nothing prioritized)
- Client-side rendering when SSR would eliminate the LCP problem entirely
- Focusing on bundle size when the real bottleneck is TTFB
- Using
will-change on everything (wastes GPU memory)