Optimizes application performance across frontend, backend, queries, and databases. Use when performance requirements exist, when you suspect performance…
Performance Optimization
Overview
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
Heap snapshot analysis
CPU spikes
Synchronous heavy computation, regex backtracking
CPU profiling
High latency
Missing caching, redundant computation, network hops
Trace requests through the stack
Step 3: Fix Common Anti-Patterns
N+1 Queries (Backend)
// BAD: N+1 — one query per task for the owner
const tasks = await db.tasks.findMany();
for (const task of tasks) {
task.owner = await db.users.findUnique({ where: { id: task.ownerId } });
}
// GOOD: Single query with join/include
const tasks = await db.tasks.findMany({
include: { owner: true },
});
Unbounded Data Fetching
// BAD: Fetching all records
const allTasks = await db.tasks.findMany();
// GOOD: Paginated with limits
const tasks = await db.tasks.findMany({
take: 20,
skip: (page - 1) * 20,
orderBy: { createdAt: 'desc' },
});
Missing Image Optimization (Frontend)
<!-- BAD: No dimensions, no format optimization -->
<img src="/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) -->
<source
media="(max-width: 767px)"
srcset="/hero-mobile-400.avif 400w, /hero-mobile-800.avif 800w"
sizes="100vw"
width="800"
height="1000"
type="image/avif"
/>
<source
media="(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) -->
<source
srcset="/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"
/>
<source
srcset="/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"
/>
<img
src="/hero-desktop.jpg"
width="1200"
height="600"
fetchpriority="high"
alt="Hero image description"
/>
</picture>
<!-- GOOD: Below-the-fold image — lazy loaded + async decoding -->
<img
src="/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-render
function TaskList() {
return <TaskFilters options={{ sortBy: 'date', order: 'desc' }} />;
}
// GOOD: Stable reference
const DEFAULT_OPTIONS = { sortBy: 'date', order: 'desc' } as const;
function TaskList() {
return <TaskFilters options={DEFAULT_OPTIONS} />;
}
// Use React.memo for expensive components
const TaskItem = React.memo(function TaskItem({ task }: Props) {
return <div>{/* expensive render */}</div>;
});
// Use useMemo for expensive computations
function TaskStats({ 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 features
const ChartLibrary = lazy(() => import('./ChartLibrary'));
// GOOD: Route-level code splitting wrapped in Suspense
const SettingsPage = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<SettingsPage />
</Suspense>
);
}
Missing Caching (Backend)
// Cache frequently-read, rarely-changed data
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let cachedConfig: AppConfig | null = null;
let cacheExpiry = 0;
async function getAppConfig(): Promise<AppConfig> {
if (cachedConfig && Date.now() < cacheExpiry) {
return cachedConfig;
}
cachedConfig = await db.config.findFirst();
cacheExpiry = Date.now() + CACHE_TTL;
return cachedConfig;
}
// HTTP caching headers for static assets
app.use('/static', express.static('public', {
maxAge: '1y', // Cache for 1 year
immutable: true, // Never revalidate (use content hashing in filenames)
}));
// Cache-Control for API responses
res.set('Cache-Control', 'public, max-age=300'); // 5 minutes
Step 4: Verify (Keep or Revert)
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
Enforce in CI:
# Bundle size check
npx bundlesize --config bundlesize.config.json
# Lighthouse CI
npx lhci autorun
See Also
For detailed performance checklists, optimization commands, and anti-pattern reference, see ../../references/performance-checklist.md.
Common Rationalizations
Rationalization
Reality
"We'll optimize later"
Performance debt compounds. Fix obvious anti-patterns now, defer micro-optimizations.
"It's fast on my machine"
Your machine isn't the user's. Profile on representative hardware and networks.
"This optimization is obvious"
If you didn't measure, you don't know. Profile first.
"Users won't notice 100ms"
Research shows 100ms delays impact conversion rates. Users notice more than you think.
"The framework handles performance"
Frameworks prevent some issues but can't fix N+1 queries or oversized bundles.
"It didn't help much, but it doesn't hurt"
Neutral changes are a revert. You pay maintenance on them forever and got nothing back.
"We already wrote it, may as well keep it"
Sunk cost. The measurement doesn't care how long the change took to write.
"The improvement is obvious, no need to re-measure"
Then re-measuring is cheap and proves it. Unmeasured wins are how neutral complexity lands.
Red Flags
Optimization without profiling data to justify it
N+1 query patterns in data fetching
List endpoints without pagination
Images without dimensions, lazy loading, or responsive sizes
Bundle size growing without review
No performance monitoring in production
React.memo and useMemo everywhere (overusing is as bad as underusing)
Optimizations kept without a re-measurement that justifies them
Several optimizations bundled into one measurement, so no single change can be attributed
A "win" that required a test to be changed, skipped, or deleted
The same failed optimization attempted more than once because nobody recorded the first attempt
Verification
After any performance-related change:
Before and after measurements exist (specific numbers)
The result was re-measured the same way as the baseline (same command, same conditions)
The improvement exceeds run-to-run variance, not just the mean
Changes that didn't beat the baseline were reverted, not kept as neutral
Attempts are logged, kept and reverted alike, so a dead idea isn't re-run
The specific bottleneck is identified and addressed
Core Web Vitals are within "Good" thresholds
Bundle size hasn't increased significantly
No N+1 queries in new data fetching code
Performance budget passes in CI (if configured)
Existing tests still pass (optimization didn't break behavior)don't have the plugin yet? install it then click "run inline in claude" again.
performance optimization is a measurement-driven discipline. measure first, identify the actual bottleneck (not the assumed one), fix it, then re-measure to confirm the fix worked. only optimize what data proves matters. premature optimization adds complexity without improving user experience. use this skill when performance requirements exist in the spec, when users report slow behavior, when core web vitals fall below thresholds, or when a change introduced a regression.
measurement tools (choose based on use case)
performance budgets (thresholds)
core web vitals baselines
external connections
baseline context
identify what is slow using the symptom tree below. match your user complaint (slow page load, sluggish interaction, etc.) to a category.
choose measurement method based on symptom type:
npx lighthouse <url> --output=json)console.time('db-query') and console.timeEnd('db-query'))import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
run the measurement in fixed conditions:
output: baseline metrics document with timestamp, conditions, and raw numbers (not smoothed or cherry-picked).
symptom tree (where to start measuring)
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 (over 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 request 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
map your symptom to the likely cause table below.
gather profiling evidence:
node --prof app.js then node --prof-process isolate-*.log), or use an APM tool to attribute latency to functionsEXPLAIN ANALYZE in postgres, similar in mysql/mongo), look for full table scans, missing indexes, or N+1 patternsnarrow to one specific bottleneck. do not attempt to fix multiple issues in one change; each fix must be measurable in isolation.
output: bottleneck identification with evidence (e.g., "database query taking 800ms, full table scan on users table, missing index on user_id").
bottleneck reference
frontend symptoms:
backend symptoms:
implement a fix targeting only the bottleneck identified in step 2. use the anti-pattern reference below as a guide. commit the change with a clear message describing what is being fixed and why (e.g., "add include on user relation to fix N+1 in task list endpoint").
common anti-patterns and fixes
N+1 queries (backend)
// bad: one query per task for the owner
const tasks = await db.tasks.findMany();
for (const task of tasks) {
task.owner = await db.users.findUnique({ where: { id: task.ownerId } });
}
// good: single query with join/include
const tasks = await db.tasks.findMany({
include: { owner: true },
});
unbounded data fetching
// bad: fetching all records
const allTasks = await db.tasks.findMany();
// good: paginated with limits
const tasks = await db.tasks.findMany({
take: 20,
skip: (page - 1) * 20,
orderBy: { createdAt: 'desc' },
});
missing image optimization (frontend)
<!-- bad: no dimensions, no format optimization, no priority hint -->
<img src="/hero.jpg" />
<!-- good: hero/LCP image with art direction and resolution switching -->
<picture>
<!-- mobile: portrait crop (8:10) -->
<source
media="(max-width: 767px)"
srcset="/hero-mobile-400.avif 400w, /hero-mobile-800.avif 800w"
sizes="100vw"
width="800"
height="1000"
type="image/avif"
/>
<source
media="(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) -->
<source
srcset="/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"
/>
<source
srcset="/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"
/>
<img
src="/hero-desktop.jpg"
width="1200"
height="600"
fetchpriority="high"
alt="Hero image description"
/>
</picture>
<!-- good: below-the-fold image with lazy loading and async decoding -->
<img
src="/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-render
function TaskList() {
return <TaskFilters options={{ sortBy: 'date', order: 'desc' }} />;
}
// good: stable reference
const DEFAULT_OPTIONS = { sortBy: 'date', order: 'desc' } as const;
function TaskList() {
return <TaskFilters options={DEFAULT_OPTIONS} />;
}
// use react.memo for expensive components
const TaskItem = React.memo(function TaskItem({ task }: Props) {
return <div>{/* expensive render */}</div>;
});
// use useMemo for expensive computations (only when profiler shows re-compute is slow)
function TaskStats({ tasks }: Props) {
const stats = useMemo(() => calculateStats(tasks), [tasks]);
return <div>{stats.completed} / {stats.total}</div>;
}
large bundle size
// good: dynamic import for heavy, rarely-used features
const ChartLibrary = lazy(() => import('./ChartLibrary'));
// good: route-level code splitting wrapped in Suspense
const SettingsPage = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<SettingsPage />
</Suspense>
);
}
missing caching (backend)
// cache frequently-read, rarely-changed data
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let cachedConfig: AppConfig | null = null;
let cacheExpiry = 0;
async function getAppConfig(): Promise<AppConfig> {
if (cachedConfig && Date.now() < cacheExpiry) {
return cachedConfig;
}
cachedConfig = await db.config.findFirst();
cacheExpiry = Date.now() + CACHE_TTL;
return cachedConfig;
}
// HTTP caching headers for static assets
app.use('/static', express.static('public', {
maxAge: '1y', // Cache for 1 year
immutable: true, // Never revalidate (use content hashing in filenames)
}));
// cache-control for API responses
res.set('Cache-Control', 'public, max-age=300'); // 5 minutes
re-run the exact measurement from step 1 under identical conditions (same device, network, cache state, minimum 3 runs).
compare the new result against the baseline. do not average across old and new runs; keep them separate.
check if the improvement exceeds run-to-run variance. if variance is ±15ms and the improvement is 10ms, that is inside noise and does not count.
verify that all tests still pass (do not change tests to hide a regression).
output: before/after comparison with raw numbers, variance range, and decision (keep or revert).
apply this matrix strictly:
| result vs baseline | verdict |
|---|---|
| past the threshold AND tests green | keep. commit with before/after numbers in the message. |
| within noise (no measurable change) | revert. neutral changes are a revert. |
| worse | revert. |
| improved BUT a test went red | revert. a regression wearing a win's clothing is still a regression. |
sunk cost is not a reason to keep a change. if you wrote it and it did not work, revert it. the codebase accretes complexity that never bought anything, and you maintain it forever.
if the optimization was kept, add a performance assertion in CI:
npx bundlesize --config bundlesize.config.jsonnpx lhci autorun --config=lighthouserc.jsonlog the optimization attempt (keep and reverted alike) in a PERF.md or PR checklist so the same dead idea does not get re-run:
| idea | baseline → result | verdict | why |
|---|---|---|---|
| memoize row component | INP 240ms → 235ms | reverted | inside noise (±15ms). rows not the bottleneck. |
| virtualize list | INP 240ms → 90ms | kept | long tasks gone from trace. |
| preconnect to API origin | LCP 2.8s → 2.8s | reverted | already same-origin. |
when to optimize vs. when to skip
synthetic vs. RUM measurement
cold cache vs. warm cache baseline
change isolation
noise vs. signal
correctness gates metrics
memoization and useMemo red flags
step 1 (measure) deliverable
{ "metric": "LCP", "baseline_mean_ms": 2800, "baseline_min_ms": 2600, "baseline_max_ms": 3100, "conditions": "4G, cold cache, moto g4", "date": "2024-01-15" }**step 2 (