Performance Optimization
Approach every performance task as an engineer who measures before cutting. Premature optimization is the source of more production incidents than slow code. The process is always: measure → identify the actual bottleneck → fix the bottleneck → measure again. Intuition about where the slowness is has a poor track record. Data does not.
Step 0: Measure First, Optimize Second
Before touching any code:
- Reproduce the problem with numbers — "it feels slow" is not a problem statement; "p99 latency is 4.2s under 100 concurrent users" is
- Identify the bottleneck — use profiling tools to find where time is actually spent; it is almost never where you expect
- Establish a baseline — measure before making any change; you cannot prove improvement without a before number
- Change one thing at a time — multiple simultaneous optimizations make it impossible to know what helped
- Measure the result — verify the optimization worked and did not regress something else
Optimization priority order:
- Algorithmic complexity — O(n²) becoming O(n log n) beats any micro-optimization
- I/O reduction — fewer DB queries, fewer HTTP calls, smaller payloads
- Caching — avoid recomputing or re-fetching what has not changed
- Parallelism — do independent work concurrently instead of sequentially
- Micro-optimization — only after the above have been exhausted
Frontend Performance
Measure with real tools:
- Lighthouse CI in the CI pipeline — fail builds that regress Core Web Vitals
- Chrome DevTools Performance panel — flame charts for CPU, network waterfall for load
- WebPageTest — real device testing, multiple geographic locations
web-vitals library — measure LCP, FID/INP, CLS in production with real user data
Core Web Vitals targets:
- LCP (Largest Contentful Paint) < 2.5s
- INP (Interaction to Next Paint) < 200ms
- CLS (Cumulative Layout Shift) < 0.1
Bundle size:
- Analyze with
webpack-bundle-analyzer or vite-bundle-visualizer — know what is in the bundle
- Code split at route boundaries — never ship one monolithic bundle
- Tree-shake unused exports — ensure bundler tree-shaking is working
- Lazy-load heavy components and libraries: charting, rich text editors, PDF renderers
- Audit and remove unused dependencies — they ship to the client even if unused
- Target: initial JS bundle < 200KB gzipped for most applications
Images:
- Use
next/image or equivalent — automatic WebP, lazy loading, correct sizing
- Specify
width and height on every image — prevents layout shift
- Use CSS sprites or SVG for icons — not dozens of small PNG requests
- Compress images before serving — never serve raw camera images
JavaScript execution:
- Avoid long tasks on the main thread (> 50ms) — they block user interaction
- Move heavy computation to Web Workers
- Debounce / throttle scroll, resize, and input event handlers
- Avoid layout thrashing — batch DOM reads and writes; do not interleave them
React.memo, useMemo, useCallback only when profiling shows a measurable benefit — they add complexity and are commonly overused
Network:
- HTTP/2 or HTTP/3 — multiplexing eliminates per-request connection overhead
- Preconnect to critical third-party origins:
<link rel="preconnect">
- Preload critical assets: fonts, hero images, above-the-fold CSS
- Use a CDN for static assets — serve from the edge, not the origin
- Cache static assets aggressively with content-hashed filenames —
main.abc123.js can be cached forever
API Latency
Find the bottleneck — use distributed tracing:
- OpenTelemetry spans on every significant operation — DB query, external HTTP call, cache lookup, business logic
- Look for: sequential calls that could be parallel, N+1 patterns, slow DB queries, unnecessary external calls
Common API latency culprits and fixes:
| Problem |
Diagnosis |
Fix |
| N+1 queries |
ORM generates one query per item in a list |
Eager load with joins or IN query |
| Missing index |
EXPLAIN ANALYZE shows seq scan |
Add targeted index |
| Synchronous external calls |
Trace shows sequential HTTP calls |
Fan out with Promise.all / asyncio.gather |
| Oversized response |
Response payload is large |
Paginate; return only requested fields |
| No caching |
Same data fetched on every request |
Cache at service or HTTP layer |
| Connection overhead |
New DB connection per request |
Connection pooling |
| Serialization overhead |
Large object serialized repeatedly |
Cache serialized form |
Response size matters:
- Return only the fields the client needs — avoid
SELECT * and avoid serializing entire domain objects to API responses
- Compress responses with gzip or brotli — most frameworks enable this with one config line
- Paginate — a response with 10,000 items is slow to serialize, transmit, and parse
Caching strategy:
- Cache at the layer closest to the consumer
- HTTP cache headers for public, stable content —
Cache-Control: public, max-age=3600
- Application cache (Redis) for: computed results, external API responses, expensive DB queries
- Define invalidation before caching — stale data is a correctness bug, not just a performance issue
- Cache hit rate is a metric — monitor it; a low hit rate means the cache is not helping
Database Performance
Slow query workflow:
- Enable slow query log — capture all queries above a threshold (e.g., > 100ms)
EXPLAIN ANALYZE on the slow query — read the actual plan, not the estimated one
- Look for: seq scans on large tables, nested loops on large sets, high row estimates vs. actuals
- Add the appropriate index — verify with
EXPLAIN ANALYZE that it is used
- Run
ANALYZE if estimates are far off — statistics may be stale
N+1 query pattern:
# ❌ N+1 — one query for the list, one per item
orders = db.query("SELECT * FROM orders WHERE user_id = ?", user_id)
for order in orders:
items = db.query("SELECT * FROM order_items WHERE order_id = ?", order.id)
# ✅ Single query with join or IN clause
orders = db.query("""
SELECT o.*, oi.*
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.user_id = ?
""", user_id)
Index effectiveness:
- Use covering indexes for hot read paths — include all columns the query needs to avoid heap fetches
- Use partial indexes for sparse conditions — index only the subset of rows the query filters on
- Monitor index usage — drop indexes that are never used; they slow every write
- Index bloat accumulates over time —
REINDEX CONCURRENTLY periodically on heavily updated tables
Connection pooling:
- Never open a new DB connection per request — pool connections at the application layer (PgBouncer, SQLAlchemy pool, HikariCP)
- Monitor connection pool utilisation — exhausted pools queue requests and spike latency
- Set pool size based on DB server capacity, not application concurrency
Query patterns:
- Avoid
SELECT * — fetch only needed columns; reduces data transfer and enables covering indexes
- Avoid
OFFSET pagination at scale — use cursor-based pagination
- Avoid
LIKE '%term%' on large tables — use full-text search indexes
- Use
EXISTS instead of COUNT when checking existence — stops scanning after the first match
Memory Leak Detection
Node.js:
- Heap snapshots in Chrome DevTools — take two snapshots separated by a period of activity; compare retained objects
--inspect flag to attach Chrome DevTools to a Node process
- Common sources: event listeners not removed, global state that accumulates, closures holding large objects, unbounded caches
Python:
tracemalloc — trace memory allocations to their source
memory_profiler — line-by-line memory usage
- Common sources: growing lists/dicts never cleared, circular references (though Python GC handles most), large objects cached without eviction
General signals:
- Steady memory growth that does not plateau — the hallmark of a leak
- Memory growth correlated with request count — something is accumulating per request
- Out-of-memory crashes on long-running processes
Prevention:
- Bounded caches — LRU cache with a max size; unbounded caches are memory leaks
- Remove event listeners on cleanup — every
addEventListener needs a corresponding removeEventListener on teardown
- Weak references for caches where eviction on GC is acceptable
CPU Bottlenecks
Profiling tools:
- Node.js:
--prof flag + node --prof-process; Chrome DevTools flame chart
- Python:
cProfile + snakeviz for visualisation; py-spy for sampling a live process without restart
- Go:
pprof
Common CPU bottlenecks:
- Synchronous JSON serialization of large objects — consider streaming serialization or pre-serialization
- Regular expression on large inputs — catastrophic backtracking on poorly written regex; test with ReDoS tools
- Cryptographic operations on the request path — move to background workers if not latency-critical
- Sorting large collections — O(n log n) on every request; cache sorted results
- Unnecessary re-renders in React — profile with the React DevTools Profiler
Parallelism:
- CPU-bound work in Node.js blocks the event loop — offload to worker threads or a separate process
- Python GIL limits CPU parallelism in threads — use
multiprocessing for CPU-bound work; async/threads for I/O-bound work
- Horizontal scaling is not a substitute for fixing an O(n²) algorithm — fix the algorithm first
Bundled Reference
Read measurement-plan.md before collecting a baseline or interpreting a before/after performance result.
Definition of Done — Performance Work
1---2name: performance-optimizer3description: Expert performance analysis and optimization guidance. Use when the user asks about frontend optimization, API latency, database optimization, memory leaks, CPU bottlenecks, or profiling.4---56# Performance Optimization78Approach every performance task as an engineer who measures before cutting. Premature optimization is the source of more production incidents than slow code. The process is always: measure → identify the actual bottleneck → fix the bottleneck → measure again. Intuition about where the slowness is has a poor track record. Data does not.910---1112## Step 0: Measure First, Optimize Second1314Before touching any code:15161. **Reproduce the problem with numbers** — "it feels slow" is not a problem statement; "p99 latency is 4.2s under 100 concurrent users" is172. **Identify the bottleneck** — use profiling tools to find where time is actually spent; it is almost never where you expect183. **Establish a baseline** — measure before making any change; you cannot prove improvement without a before number194. **Change one thing at a time** — multiple simultaneous optimizations make it impossible to know what helped205. **Measure the result** — verify the optimization worked and did not regress something else2122**Optimization priority order:**231. Algorithmic complexity — O(n²) becoming O(n log n) beats any micro-optimization242. I/O reduction — fewer DB queries, fewer HTTP calls, smaller payloads253. Caching — avoid recomputing or re-fetching what has not changed264. Parallelism — do independent work concurrently instead of sequentially275. Micro-optimization — only after the above have been exhausted2829---3031## Frontend Performance3233**Measure with real tools:**34- Lighthouse CI in the CI pipeline — fail builds that regress Core Web Vitals35- Chrome DevTools Performance panel — flame charts for CPU, network waterfall for load36- WebPageTest — real device testing, multiple geographic locations37- `web-vitals` library — measure LCP, FID/INP, CLS in production with real user data3839**Core Web Vitals targets:**40- LCP (Largest Contentful Paint) < 2.5s41- INP (Interaction to Next Paint) < 200ms42- CLS (Cumulative Layout Shift) < 0.14344**Bundle size:**45- Analyze with `webpack-bundle-analyzer` or `vite-bundle-visualizer` — know what is in the bundle46- Code split at route boundaries — never ship one monolithic bundle47- Tree-shake unused exports — ensure bundler tree-shaking is working48- Lazy-load heavy components and libraries: charting, rich text editors, PDF renderers49- Audit and remove unused dependencies — they ship to the client even if unused50- Target: initial JS bundle < 200KB gzipped for most applications5152**Images:**53- Use `next/image` or equivalent — automatic WebP, lazy loading, correct sizing54- Specify `width` and `height` on every image — prevents layout shift55- Use CSS sprites or SVG for icons — not dozens of small PNG requests56- Compress images before serving — never serve raw camera images5758**JavaScript execution:**59- Avoid long tasks on the main thread (> 50ms) — they block user interaction60- Move heavy computation to Web Workers61- Debounce / throttle scroll, resize, and input event handlers62- Avoid layout thrashing — batch DOM reads and writes; do not interleave them63- `React.memo`, `useMemo`, `useCallback` only when profiling shows a measurable benefit — they add complexity and are commonly overused6465**Network:**66- HTTP/2 or HTTP/3 — multiplexing eliminates per-request connection overhead67- Preconnect to critical third-party origins: `<link rel="preconnect">`68- Preload critical assets: fonts, hero images, above-the-fold CSS69- Use a CDN for static assets — serve from the edge, not the origin70- Cache static assets aggressively with content-hashed filenames — `main.abc123.js` can be cached forever7172---7374## API Latency7576**Find the bottleneck — use distributed tracing:**77- OpenTelemetry spans on every significant operation — DB query, external HTTP call, cache lookup, business logic78- Look for: sequential calls that could be parallel, N+1 patterns, slow DB queries, unnecessary external calls7980**Common API latency culprits and fixes:**8182| Problem | Diagnosis | Fix |83|---------|-----------|-----|84| N+1 queries | ORM generates one query per item in a list | Eager load with joins or `IN` query |85| Missing index | `EXPLAIN ANALYZE` shows seq scan | Add targeted index |86| Synchronous external calls | Trace shows sequential HTTP calls | Fan out with `Promise.all` / `asyncio.gather` |87| Oversized response | Response payload is large | Paginate; return only requested fields |88| No caching | Same data fetched on every request | Cache at service or HTTP layer |89| Connection overhead | New DB connection per request | Connection pooling |90| Serialization overhead | Large object serialized repeatedly | Cache serialized form |9192**Response size matters:**93- Return only the fields the client needs — avoid `SELECT *` and avoid serializing entire domain objects to API responses94- Compress responses with gzip or brotli — most frameworks enable this with one config line95- Paginate — a response with 10,000 items is slow to serialize, transmit, and parse9697**Caching strategy:**98- Cache at the layer closest to the consumer99- HTTP cache headers for public, stable content — `Cache-Control: public, max-age=3600`100- Application cache (Redis) for: computed results, external API responses, expensive DB queries101- Define invalidation before caching — stale data is a correctness bug, not just a performance issue102- Cache hit rate is a metric — monitor it; a low hit rate means the cache is not helping103104---105106## Database Performance107108**Slow query workflow:**1091. Enable slow query log — capture all queries above a threshold (e.g., > 100ms)1102. `EXPLAIN ANALYZE` on the slow query — read the actual plan, not the estimated one1113. Look for: seq scans on large tables, nested loops on large sets, high row estimates vs. actuals1124. Add the appropriate index — verify with `EXPLAIN ANALYZE` that it is used1135. Run `ANALYZE` if estimates are far off — statistics may be stale114115**N+1 query pattern:**116```117# ❌ N+1 — one query for the list, one per item118orders = db.query("SELECT * FROM orders WHERE user_id = ?", user_id)119for order in orders:120 items = db.query("SELECT * FROM order_items WHERE order_id = ?", order.id)121122# ✅ Single query with join or IN clause123orders = db.query("""124 SELECT o.*, oi.*125 FROM orders o126 JOIN order_items oi ON oi.order_id = o.id127 WHERE o.user_id = ?128""", user_id)129```130131**Index effectiveness:**132- Use covering indexes for hot read paths — include all columns the query needs to avoid heap fetches133- Use partial indexes for sparse conditions — index only the subset of rows the query filters on134- Monitor index usage — drop indexes that are never used; they slow every write135- Index bloat accumulates over time — `REINDEX CONCURRENTLY` periodically on heavily updated tables136137**Connection pooling:**138- Never open a new DB connection per request — pool connections at the application layer (PgBouncer, SQLAlchemy pool, HikariCP)139- Monitor connection pool utilisation — exhausted pools queue requests and spike latency140- Set pool size based on DB server capacity, not application concurrency141142**Query patterns:**143- Avoid `SELECT *` — fetch only needed columns; reduces data transfer and enables covering indexes144- Avoid `OFFSET` pagination at scale — use cursor-based pagination145- Avoid `LIKE '%term%'` on large tables — use full-text search indexes146- Use `EXISTS` instead of `COUNT` when checking existence — stops scanning after the first match147148---149150## Memory Leak Detection151152**Node.js:**153- Heap snapshots in Chrome DevTools — take two snapshots separated by a period of activity; compare retained objects154- `--inspect` flag to attach Chrome DevTools to a Node process155- Common sources: event listeners not removed, global state that accumulates, closures holding large objects, unbounded caches156157**Python:**158- `tracemalloc` — trace memory allocations to their source159- `memory_profiler` — line-by-line memory usage160- Common sources: growing lists/dicts never cleared, circular references (though Python GC handles most), large objects cached without eviction161162**General signals:**163- Steady memory growth that does not plateau — the hallmark of a leak164- Memory growth correlated with request count — something is accumulating per request165- Out-of-memory crashes on long-running processes166167**Prevention:**168- Bounded caches — LRU cache with a max size; unbounded caches are memory leaks169- Remove event listeners on cleanup — every `addEventListener` needs a corresponding `removeEventListener` on teardown170- Weak references for caches where eviction on GC is acceptable171172---173174## CPU Bottlenecks175176**Profiling tools:**177- Node.js: `--prof` flag + `node --prof-process`; Chrome DevTools flame chart178- Python: `cProfile` + `snakeviz` for visualisation; `py-spy` for sampling a live process without restart179- Go: `pprof`180181**Common CPU bottlenecks:**182- Synchronous JSON serialization of large objects — consider streaming serialization or pre-serialization183- Regular expression on large inputs — catastrophic backtracking on poorly written regex; test with ReDoS tools184- Cryptographic operations on the request path — move to background workers if not latency-critical185- Sorting large collections — O(n log n) on every request; cache sorted results186- Unnecessary re-renders in React — profile with the React DevTools Profiler187188**Parallelism:**189- CPU-bound work in Node.js blocks the event loop — offload to worker threads or a separate process190- Python GIL limits CPU parallelism in threads — use `multiprocessing` for CPU-bound work; async/threads for I/O-bound work191- Horizontal scaling is not a substitute for fixing an O(n²) algorithm — fix the algorithm first192193---194195## Bundled Reference196197Read [measurement-plan.md](./references/measurement-plan.md) before collecting a baseline or interpreting a before/after performance result.198199## Definition of Done — Performance Work200201- [ ] Baseline measured before optimization with specific numbers202- [ ] Bottleneck identified with profiling tools — not intuition203- [ ] One change made at a time204- [ ] Improvement verified against baseline with the same measurement method205- [ ] No regressions in correctness, memory usage, or latency on other paths206- [ ] Performance budget or SLO defined and monitored going forward207- [ ] CI check added to prevent regression (Lighthouse CI, latency threshold test, or equivalent)