A slow restaurant loses customers
Imagine a restaurant with the best food in the world, but where every dish takes 45 minutes. No matter how good it is, customers leave.
Web applications are the same. Every second of load time can cost up to 7% in conversions.
Performance isn't premature optimization. It's respect for the user's time.
Core Web Vitals: The metrics that matter
Google measures 3 main metrics that affect SEO and experience:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CORE WEB VITALS โ
โโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโค
โ LCP โ INP โ CLS โ
โ < 2.5s โ < 200ms โ < 0.1 โ
โ โ โ โ
โ Largest โ Interaction โ Cumulative โ
โ Contentful โ to Next โ Layout โ
โ Paint โ Paint โ Shift โ
โ โ โ โ
โ "Fast load" โ "Responsive" โ "Visually stable" โ
โโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโ
| Metric | What it measures | Good | Bad |
|---|---|---|---|
| LCP | How long main content takes | < 2.5s | > 4s |
| INP | Response to interactions | < 200ms | > 500ms |
| CLS | How much layout shifts | < 0.1 | > 0.25 |
The browser pipeline
Understanding how the browser renders helps you optimize.
HTML โโโ DOM
โ
CSS โโโ CSSOM โโโ Render Tree โโโ Layout โโโ Paint โโโ Composite
What blocks rendering
- Synchronous JavaScript: Blocks HTML parsing
- CSS in <head>: Blocks render (but necessary)
- External fonts: Can cause FOIT/FOUT
JavaScript: The bottleneck
The Event Loop
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CALL STACK โ
โ (Executes synchronous code, one at a time) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โฒ โ
โ โผ
โโโโโโโโโโดโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ TASK QUEUE โ โ MICROTASK QUEUE โ
โ (setTimeout, โ โ (Promises, async/await)โ
โ events) โ โ (Higher priority) โ
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโ
Problem: Main thread blocking
// BAD: Blocks UI for 500ms
function processData(items) {
items.forEach(item => heavyCalculation(item));
}
// GOOD: Divide into chunks
async function processDataAsync(items) {
const chunkSize = 100;
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
chunk.forEach(item => heavyCalculation(item));
await new Promise(r => setTimeout(r, 0)); // Yield to browser
}
}
Bundle optimization
Code Splitting
// Without splitting: everything loads at start
import { Dashboard } from './Dashboard';
import { Admin } from './Admin';
import { Reports } from './Reports';
// With splitting: loads on demand
const Dashboard = lazy(() => import('./Dashboard'));
const Admin = lazy(() => import('./Admin'));
const Reports = lazy(() => import('./Reports'));
Tree Shaking
// BAD: Imports entire library (100KB)
import _ from 'lodash';
_.debounce(fn, 300);
// GOOD: Only what you need (2KB)
import debounce from 'lodash/debounce';
debounce(fn, 300);
Image optimization
Modern formats
| Format | Use | Savings vs JPEG |
|---|---|---|
| WebP | General, wide support | 25-35% |
| AVIF | Best compression, less support | 50%+ |
Responsive images
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img
src="hero.jpg"
alt="Hero"
loading="lazy"
width="1200"
height="600"
>
</picture>
Next.js Image
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // For above the fold images
placeholder="blur" // Loading effect
/>
Strategic caching
Cache levels
User โโโ Browser Cache โโโ CDN Cache โโโ Server โโโ DB
(localStorage, (Edge) (Redis)
sessionStorage)
HTTP Cache Headers
Cache-Control: public, max-age=31536000, immutable
โ โ โ
โ โ โโ Don't revalidate
โ โโ 1 year in seconds
โโ CDN can cache
Strategy by resource type
| Resource | Cache-Control | Why |
|---|---|---|
| JS/CSS with hash | max-age=31536000, immutable | Hash changes if file changes |
| HTML | no-cache | Always validate with server |
| Dynamic API | private, max-age=0 | Fresh data |
| Static images | max-age=86400 | 1 day, CDN |
Database performance
Indexes: The key
-- Without index: Full table scan (slow)
SELECT * FROM users WHERE email = 'test@example.com';
-- Time: 500ms on 1M rows
-- With index: Index lookup (fast)
CREATE INDEX idx_users_email ON users(email);
-- Time: 2ms
EXPLAIN: Understand your queries
EXPLAIN ANALYZE SELECT * FROM orders
WHERE user_id = 123 AND created_at > '2024-01-01';
-- Look for:
-- - Seq Scan (bad on large tables)
-- - Index Scan (good)
-- - Estimated vs actual rows
The N+1 problem
// BAD: N+1 queries
const users = await User.findAll();
for (const user of users) {
user.orders = await Order.findByUser(user.id); // 1 query per user
}
// 1 + N queries
// GOOD: Eager loading
const users = await User.findAll({
include: [{ model: Order }]
});
// 1 query with JOIN
Profiling tools
Chrome DevTools
- Performance tab: Record load timeline
- Network tab: Request waterfall
- Lighthouse: Complete audit
What to look for in Performance tab
Timeline:
โโโ Loading (blue): Parsing HTML
โโโ Scripting (yellow): JavaScript
โโโ Rendering (purple): Layout, style
โโโ Painting (green): Drawing pixels
If yellow dominates โ Optimize JS
If purple dominates โ Reduce reflows
Performance checklist
Before deploy
- Lighthouse score > 90
- LCP < 2.5s
- Bundle size < 200KB (initial)
- Images in WebP/AVIF
- Lazy loading on below fold images
- Code splitting active
- Cache headers configured
In production
- CDN configured
- Gzip/Brotli active
- HTTP/2 enabled
- Indexes on slow queries
- Core Web Vitals monitoring
Resources
Practice
-> Performance Audit - Optimize a slow app step by step