| name | performance-optimization |
| description | Use when a measured regression, explicit performance target, or profiler evidence identifies work to optimize. |
Performance Optimization
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.
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, confirm improvement
5. GUARD → Add monitoring or tests to prevent regression
Step 1: Measure
Frontend:
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);
Backend:
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, check API/database
│ └── 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)
const tasks = await db.tasks.findMany();
for (const task of tasks) {
task.owner = await db.users.findUnique({ where: { id: task.ownerId } });
}
const tasks = await db.tasks.findMany({
include: { owner: true },
});
Unbounded Data Fetching
const allTasks = await db.tasks.findMany();
const tasks = await db.tasks.findMany({
take: 20,
skip: (page - 1) * 20,
orderBy: { createdAt: 'desc' },
});
Missing Image Optimization (Frontend)
<img src="/hero.jpg" />
<img
src="/hero.jpg"
srcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
width="1200"
height="600"
loading="lazy"
alt="Hero image description"
/>
Async Waterfalls (highest-impact server/client fix)
const user = await fetchUser(id);
const orders = await fetchOrders(id);
const [user, orders] = await Promise.all([fetchUser(id), fetchOrders(id)]);
- Check cheap synchronous conditions (auth, feature flags, validation) before awaiting anything.
- In API routes: start requests early, await late — kick off promises up front, await at the point of use.
- Dedup repeated per-request reads on the server (
React.cache() in RSC); hoist static I/O out of request handlers.
Unnecessary Re-renders (React)
function TaskList() {
return <TaskFilters options={{ sortBy: 'date', order: 'desc' }} />;
}
const DEFAULT_OPTIONS = { sortBy: 'date', order: 'desc' } as const;
function TaskList() {
return <TaskFilters options={DEFAULT_OPTIONS} />;
}
const TaskItem = React.memo(function TaskItem({ task }: Props) {
return <div>{/* expensive render */}</div>;
});
function TaskStats({ tasks }: Props) {
const stats = useMemo(() => (tasks), [tasks]);
;
}
Further re-render and rendering rules:
- Never define a component inside another component — it's a new type every render, remounting the whole subtree.
- Derive state during render instead of syncing it in effects; use functional
setState when next state depends on previous.
useDeferredValue for expensive UI derived from fast-changing input; passive listeners for scroll/touch.
- Long lists:
content-visibility: auto or virtualization; hoist static JSX and RegExp out of render paths and loops; use Map for O(1) lookups in hot paths.
Large Bundle Size
import { format } from 'date-fns';
import { format } from 'date-fns/format';
const ChartLibrary = lazy(() => import('./ChartLibrary'));
- No barrel imports — import from concrete module paths so bundlers can tree-shake and analyze statically.
- Defer third-party scripts (analytics, widgets) so they never block first paint.
Missing Caching (Backend)
const CACHE_TTL = 5 * 60 * 1000;
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;
}
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true,
}));
res.set('Cache-Control', 'public, max-age=300');
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:
npx bundlesize --config bundlesize.config.json
npx lhci autorun
Verification
After any performance-related change: