feat(performance): deepen backend and database substance

#358 broadened the performance-optimization description to cover
"frontend, backend, queries, and databases", but the body's backend
coverage was a single N+1 example, against the full Core Web Vitals
and art-direction treatment on the frontend side. Routing a "slow
query" or "connection pool" prompt here landed on a snippet.

Adds three measure-first patterns inline, each with a "when NOT to":

- Query plans: read EXPLAIN ANALYZE before indexing; composite column
  order (equality first, then range/sort); the four cases where an
  index does not help; revert an index that did not change the plan.
- Connection pool exhaustion: the distinctive symptom, one pool per
  process, why a bigger pool relocates the queue instead of removing
  it, and the proxy answer for serverless.
- Caching: layer choice (in-process, shared, edge), key design as a
  correctness concern, one invalidation strategy rather than an
  accidental mix, and stampede protection.

Deeper material goes to references/performance-checklist.md to keep
SKILL.md readable: read/write patterns, negative caching, request
coalescing, and query-plan, index, pooling and cache checklists.

Closes #378

Claude-Session: https://claude.ai/code/session_01TuWNaEV9RFXpTxqeBRTRg8
This commit is contained in:
Joan Leon committed 2026-08-27 18:37:32 +02:00
1 parent 7cb7a20bb3
commit 0ce0107f4f
2 files changed
+168

No files matched your search

+83
View File
@@ -8,6 +8,7 @@ Quick reference checklist for web application performance. Use alongside the `pe
- [TTFB Diagnosis](#ttfb-diagnosis)
- [Frontend Checklist](#frontend-checklist)
- [Backend Checklist](#backend-checklist)
- [Caching Strategies](#caching-strategies)
- [Measurement Commands](#measurement-commands)
- [Common Anti-Patterns](#common-anti-patterns)
@@ -92,6 +93,30 @@ When TTFB is slow (> 800ms), check each component in DevTools Network waterfall:
- [ ] Connection pooling configured
- [ ] Slow query logging enabled
#### Query plans
- [ ] `EXPLAIN ANALYZE` captured **before** the fix, not just after — it is the baseline
- [ ] `Seq Scan` on a large table understood: index missing, unusable, or genuinely not worth it
- [ ] Estimated vs actual `rows=` within an order of magnitude (if not, refresh statistics before touching indexes)
- [ ] No `Sort` node that a composite index could absorb
- [ ] Plan re-checked after the change — an index that did not change the plan gets reverted
#### Index strategy
- [ ] Composite index column order is equality first, then range/sort
- [ ] Index covers the query shape (filter + sort), not just one column in isolation
- [ ] Covering index considered for hot read paths (index-only scan avoids the heap fetch)
- [ ] Not indexing low-selectivity columns (one value dominating the table)
- [ ] Expression index used where the query applies a function (`lower(email)`)
- [ ] Full-text or trigram index used for leading-wildcard search, not a B-tree
- [ ] Write cost measured on write-heavy tables (every index taxes every `INSERT`/`UPDATE`)
- [ ] Unused and duplicate indexes dropped (they cost writes and buy nothing)
#### Connection pooling
- [ ] One pool per process, not per request or per module
- [ ] `instances × pool max` stays under the database's `max_connections`
- [ ] `connectionTimeoutMillis` set so exhaustion fails fast instead of queueing forever
- [ ] Exhaustion diagnosed before resizing: find what holds connections (long transactions, missing `await`, leaked clients)
- [ ] Serverless / autoscaling fronted by a multiplexing proxy (pgbouncer, RDS Proxy) rather than a larger pool
### API
- [ ] Response times < 200ms (p95)
- [ ] No synchronous heavy computation in request handlers
@@ -105,6 +130,58 @@ When TTFB is slow (> 800ms), check each component in DevTools Network waterfall:
- [ ] Horizontal scaling configured (if needed)
- [ ] Health check endpoint for load balancer
## Caching Strategies
The decision material (which layer, which invalidation strategy, what never to cache) lives in the `performance-optimization` skill. This section covers the read/write patterns and the checklist.
### Read and write patterns
| Pattern | How it works | Use when | Watch out for |
|---|---|---|---|
| **Cache-aside** (lazy) | App checks cache, on miss reads origin and populates | Default choice; read-heavy, tolerant of a cold first hit | Every miss hits the origin, so it needs stampede protection |
| **Read-through** | Cache layer itself loads on miss | You want the load path in one place, not at every call site | Hides origin latency; a slow origin looks like a slow cache |
| **Write-through** | Write goes to cache and origin together, synchronously | Reads must never see a stale value after a write | Adds cache latency to every write |
| **Write-behind** (write-back) | Write hits cache, origin updated asynchronously | Write-heavy, and the origin is the bottleneck | Data loss window if the cache dies before the flush. Needs durability you can defend |
### Negative caching
Cache the *absence* of a result too. A key that misses on every lookup (a nonexistent user ID probed in a loop, a 404 asset) sends every request to the origin, which is a cache that only protects the happy path.
- Store an explicit "not found" sentinel with a **shorter** TTL than positive entries
- Keep the negative TTL short enough that a newly created record appears promptly
- Never let an origin *error* become a negative cache entry, or one failing minute becomes many
### Request coalescing (stampede protection)
One recompute, N waiters. Prevents a hot key's expiry from delivering the full concurrent load to the origin:
```typescript
const inFlight = new Map<string, Promise<Row>>();
function loadOnce(key: string, fetcher: () => Promise<Row>): Promise<Row> {
const existing = inFlight.get(key);
if (existing) return existing;
const p = fetcher().finally(() => inFlight.delete(key));
inFlight.set(key, p);
return p;
}
```
For a shared cache, the same idea needs a distributed lock, or `stale-while-revalidate` so waiters serve the stale value instead of blocking.
### Cache checklist
- [ ] The cached call was measured as expensive first (caching a fast call adds a hop and buys nothing)
- [ ] Read/write ratio justifies the cache (re-read far more often than written)
- [ ] Cache key includes every input the response varies on: tenant, viewer, locale, permissions, feature flags
- [ ] No per-user data cached under a key that does not identify the user
- [ ] One invalidation strategy chosen (TTL, event/tag, or versioned keys), not an accidental mix
- [ ] Acceptable staleness window written down, not implied by whatever TTL was typed
- [ ] Stampede protection on hot keys (coalescing, lock, or `stale-while-revalidate`)
- [ ] Negative results cached with a shorter TTL; origin errors never cached
- [ ] Eviction policy and memory ceiling set (an unbounded cache is a memory leak)
- [ ] Hit rate monitored — a cache nobody measures is an assumption, and a low hit rate is pure overhead
- [ ] Nothing cached whose staleness is a correctness bug (balances, permissions, inventory at checkout)
## Measurement Commands
### INP field data and DevTools workflow
@@ -146,6 +223,12 @@ onINP(({ value, attribution }) => {
| N+1 queries | Linear DB load growth | Use joins, includes, or batch loading |
| Unbounded queries | Memory exhaustion, timeouts | Always paginate, add LIMIT |
| Missing indexes | Slow reads as data grows | Add indexes for filtered/sorted columns |
| Indexing without reading the plan | Write cost paid, read gain unproven | `EXPLAIN ANALYZE` before and after; revert if the plan is unchanged |
| Redundant / unused indexes | Every write pays for them | Audit usage stats, drop what nothing reads |
| Connection pool per request | Exhausts `max_connections` under load | One pool per process; proxy for serverless |
| Cache key missing the viewer | One user's data served to another | Key on tenant, viewer, locale, permissions |
| Unbounded cache | Memory leak wearing an optimization's clothing | Set eviction policy and a memory ceiling |
| Cache stampede on a hot key | Origin takes full concurrent load at expiry | Coalesce misses, or `stale-while-revalidate` |
| Layout thrashing | Jank, dropped frames | Batch DOM reads, then batch writes |
| Unoptimized images | Slow LCP, wasted bandwidth | Use WebP, responsive sizes, lazy load |
| Large bundles | Slow Time to Interactive | Code split, tree shake, audit deps |