Optimizes application performance across frontend, backend, queries, and databases. Use when performance requirements exist, when you suspect performance regressions, when Core Web Vitals or load times need improvement, when N+1 query patterns need fixing, or when profiling reveals bottlenecks.
Optimizes application performance across frontend, backend, queries, and databases. Use when performance requirements exist, when you suspect performance regressions, when Core Web Vitals or load times need improvement, when N+1 query patterns need fixing, or when profiling reveals bottlenecks.
Measure before optimizing. Performance work without measurement is guessing — and guessing leads to premature optimization that adds complexity without improving what matters. Profile first, identify the actual bottleneck, fix it, measure again. Optimize only what measurements prove matters.
When to Use
Performance requirements exist in the spec (load time budgets, response time SLAs)
Users or monitoring report slow behavior
Core Web Vitals scores are below thresholds
You suspect a change introduced a regression
Building features that handle large datasets or high traffic
When NOT to use: Don't optimize before you have evidence of a problem. Premature optimization adds complexity that costs more than the performance it gains.
Core Web Vitals Targets
Metric
Good
Needs Improvement
Poor
LCP (Largest Contentful Paint)
≤ 2.5s
≤ 4.0s
> 4.0s
INP (Interaction to Next Paint)
≤ 200ms
≤ 500ms
> 500ms
CLS (Cumulative Layout Shift)
≤ 0.1
≤ 0.25
> 0.25
The Optimization Workflow
1. MEASURE → Establish baseline with real data
2. IDENTIFY → Find the actual bottleneck (not assumed)
3. FIX → Address the specific bottleneck
4. VERIFY → Measure again; keep or revert
5. GUARD → Add monitoring or tests to prevent regression
Step 1: Measure
Two complementary approaches — use both:
Synthetic (Lighthouse, DevTools Performance tab): Controlled conditions, reproducible. Best for CI regression detection and isolating specific issues.
RUM (web-vitals library, CrUX): Real user data in real conditions. Required to validate that a fix actually improved user experience.
Frontend:
# Synthetic: Lighthouse in Chrome DevTools (or CI)# Chrome DevTools → Performance tab → Record# Chrome DevTools MCP → Performance trace# RUM: Web Vitals library in code
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);
Backend:
# Response time logging# Application Performance Monitoring (APM)# Database query logging with timing# Simple timing
console.time('db-query');
const result = await db.query(...);
console.timeEnd('db-query');
Where to Start Measuring
Use the symptom to decide what to measure first:
What is slow?
├── First page load
│ ├── Large bundle? --> Measure bundle size, check code splitting
│ ├── Slow server response? --> Measure TTFB in DevTools Network waterfall
│ │ ├── DNS long? --> Add dns-prefetch / preconnect for known origins
│ │ ├── TCP/TLS long? --> Enable HTTP/2, check edge deployment, keep-alive
│ │ └── Waiting (server) long? --> Profile backend, check queries and caching
│ └── Render-blocking resources? --> Check network waterfall for CSS/JS blocking
├── Interaction feels sluggish
│ ├── UI freezes on click? --> Profile main thread, look for long tasks (>50ms)
│ ├── Form input lag? --> Check re-renders, controlled component overhead
│ └── Animation jank? --> Check layout thrashing, forced reflows
├── Page after navigation
│ ├── Data loading? --> Measure API response times, check for waterfalls
│ └── Client rendering? --> Profile component render time, check for N+1 fetches
└── Backend / API
├── Single endpoint slow? --> Profile database queries, check indexes
├── All endpoints slow? --> Check connection pool, memory, CPU
└── Intermittent slowness? --> Check for lock contention, GC pauses, external deps
Step 2: Identify the Bottleneck
Common bottlenecks by category:
Frontend:
Symptom
Likely Cause
Investigation
Slow LCP
Large images, render-blocking resources, slow server
Check network waterfall, image sizes
High CLS
Images without dimensions, late-loading content, font shifts
Check layout shift attribution
Poor INP
Heavy JavaScript on main thread, large DOM updates
Check long tasks in Performance trace
Slow initial load
Large bundle, many network requests
Check bundle size, code splitting
Backend:
Symptom
Likely Cause
Investigation
Slow API responses
N+1 queries, missing indexes, unoptimized queries
Check database query log
Memory growth
Leaked references, unbounded caches, large payloads
<!-- BAD: No dimensions, no format optimization --><imgsrc="/hero.jpg" /><!-- GOOD: Hero / LCP image — art direction + resolution switching, high priority --><!--
Two techniques combined:
- Art direction (media): different crop/composition per breakpoint
- Resolution switching (srcset + sizes): right file size per screen density
--><picture><!-- Mobile: portrait crop (8:10) --><sourcemedia="(max-width: 767px)"srcset="/hero-mobile-400.avif 400w, /hero-mobile-800.avif 800w"sizes="100vw"width="800"height="1000"type="image/avif"
/><sourcemedia="(max-width: 767px)"srcset="/hero-mobile-400.webp 400w, /hero-mobile-800.webp 800w"sizes="100vw"width="800"height="1000"type="image/webp"
/><!-- Desktop: landscape crop (2:1) --><sourcesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"sizes="(max-width: 1200px) 100vw, 1200px"width="1200"height="600"type="image/avif"
/><sourcesrcset="/hero-800.webp 800w, /hero-1200.webp 1200w, /hero-1600.webp 1600w"sizes="(max-width: 1200px) 100vw, 1200px"width="1200"height="600"type="image/webp"
/><imgsrc="/hero-desktop.jpg"width="1200"height="600"fetchpriority="high"alt="Hero image description"
/></picture><!-- GOOD: Below-the-fold image — lazy loaded + async decoding --><imgsrc="/content.webp"width="800"height="400"loading="lazy"decoding="async"alt="Content image description"
/>
Unnecessary Re-renders (React)
// BAD: Creates new object on every render, causing children to re-renderfunctionTaskList() {
return<TaskFiltersoptions={{sortBy: 'date', order: 'desc' }} />;
}
// GOOD: Stable referenceconstDEFAULT_OPTIONS = { sortBy: 'date', order: 'desc' } asconst;
functionTaskList() {
return<TaskFiltersoptions={DEFAULT_OPTIONS} />;
}
// Use React.memo for expensive componentsconstTaskItem = React.memo(functionTaskItem({ task }: Props) {
return<div>{/* expensive render */}</div>;
});
// Use useMemo for expensive computationsfunctionTaskStats({ tasks }: Props) {
const stats = useMemo(() =>calculateStats(tasks), [tasks]);
return<div>{stats.completed} / {stats.total}</div>;
}
Large Bundle Size
// Modern bundlers (Vite, webpack 5+) handle named imports with tree-shaking automatically,// provided the dependency ships ESM and is marked `sideEffects: false` in package.json.// Profile before changing import styles — the real gains come from splitting and lazy loading.// GOOD: Dynamic import for heavy, rarely-used featuresconstChartLibrary = lazy(() =>import('./ChartLibrary'));
// GOOD: Route-level code splitting wrapped in SuspenseconstSettingsPage = lazy(() =>import('./pages/Settings'));
functionApp() {
return (
<Suspensefallback={<Spinner />}>
<SettingsPage /></Suspense>
);
}
A fix is a hypothesis until you re-measure. This step decides whether it survives.
Re-measure the way you measured the baseline: same command, same conditions, same fixed budget (wall-clock, sample count, or request count). A baseline taken on a cold cache against a result taken on a warm one measures the cache, not your change.
Change one thing at a time. Three optimizations landed together produce one number, and you cannot attribute it. If they must ship together, measure each in isolation first.
Beat the noise, not just the mean. Repeat the measurement and compare the delta against run-to-run variance. A 3% gain inside ±5% variance is not a gain; it is a different sample.
Then decide, strictly:
Result vs. baseline
Action
Past the threshold, tests green
Keep. Commit with the before/after numbers in the message.
Within noise (no measurable change)
Revert.
Worse
Revert.
Improved, but a test went red
Revert. A regression wearing a win's clothing.
"Neutral" is a revert, not a keep. This is the step teams skip: the change is already written, throwing it away feels wasteful, so it lands unmeasured, and the codebase accretes complexity that never bought anything. Code you keep, you maintain forever. Make it pay for itself.
Correctness gates the metric. The suite stays green and the number moves. An "optimization" that wins by dropping work the product needed (skipping a validation, caching something that must be fresh, removing an await that was load-bearing) is a regression, not a win.
Log every attempt, including the reverted ones
Reverted work leaves no trace in git history, which is exactly why the same dead idea gets tried again next quarter. Keep a short ledger so a discarded idea stays discarded:
Idea
Baseline → Result
Verdict
Why
Memoize the row component
INP 240ms → 235ms
reverted
Inside noise (±15ms). Rows weren't the bottleneck.
Virtualize the list
INP 240ms → 90ms
kept
Long tasks gone from the trace.
Preconnect to the API origin
LCP 2.8s → 2.8s
reverted
Already same-origin.
A section in the PR description or a PERF.md in the repo both work. What matters is that the next person (or the next agent) reads it before proposing an experiment, and doesn't re-run one that already failed.
Performance Budget
Set budgets and enforce them:
JavaScript bundle: < 200KB gzipped (initial load)
CSS: < 50KB gzipped
Images: < 200KB per image (above the fold)
Fonts: < 100KB total
API response time: < 200ms (p95)
Time to Interactive: < 3.5s on 4G
Lighthouse Performance score: ≥ 90