# Perf Hunter

> Find and fix the actual bottleneck — not the one you assume. Profiles before optimizing, measures before and after, refuses to "rewrite for perf" without numbers. Covers React/Vue rendering, bundle size, database query plans, N+1 patterns, memory leaks, and Node.js event-loop lag. Use when the user says "make this faster", "why is this slow", "optimize this", "the page is laggy", "the query is slow", or "this endpoint times out".

- Skill: `ak-ship/perf-hunter` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ak-ship/perf-hunter`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ak-ship/perf-hunter/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: ak-ship (https://skillmd.com/u/ak-ship)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ak-ship/perf-hunter

---


# perf-hunter — measure, fix, measure again

## When to use this skill

Trigger when something is slow and someone wants it faster. Strong signals:

- "why is this slow?", "make this faster", "this is too slow"
- "the page takes 5 seconds to load"
- "the query times out"
- "we're hitting our function memory limit"
- A flame graph, profiler output, or DevTools screenshot pasted in

Do *not* trigger for: code style optimizations that don't have measured impact (use `refactor-master`), perceived perf issues without numbers ("it *feels* slow") — get numbers first.

## The output contract

A perf change that:

1. **Has a before number** — measured under realistic conditions, not synthetic best-case.
2. **Targets the actual bottleneck** — identified by profiling, not guessing.
3. **Has an after number** — same measurement, same conditions.
4. **Quantifies the trade-off** — what got harder to maintain, what runtime cost moved where.
5. **Includes a regression test** — a benchmark or a budget that fails if the perf regresses.

Without those four numbers and a regression test, it's not a perf fix — it's vibes.

## Workflow

### 1 — Define "slow"

Get specific:

- *What* operation? (page load, API call, query, render, build)
- *How slow*? (p50, p95, p99 — averages lie)
- *Compared to what*? (target, prior version, competitor)
- *Under what load*? (1 user vs 1000, cache cold vs warm, payload size)

If the user can't answer those, ask. Optimizing the wrong p50 doesn't move the p95 the users notice.

### 2 — Measure before

Pick the right tool for the surface:

**Frontend**:
- Chrome DevTools Performance panel — record a real interaction, find the long tasks
- Lighthouse for page-load metrics (LCP, INP, CLS, TBT)
- React Profiler for component render times
- `webpack-bundle-analyzer` / `rollup-plugin-visualizer` / `source-map-explorer` for bundle size

**Backend Node**:
- `--prof` for CPU profiling, then `node --prof-process` → flame chart with `0x` or `clinic flame`
- `clinic doctor` for high-level (event-loop lag, GC, CPU, memory)
- `clinic bubbleprof` for async I/O patterns
- For HTTP: `autocannon` or `k6` for load + percentiles

**Database**:
- `EXPLAIN ANALYZE` for the slow query (Postgres) — read the actual plan
- `pg_stat_statements` extension to find the queries burning the most time across the workload
- `EXPLAIN FORMAT=JSON` (MySQL), `db.collection.explain('executionStats')` (Mongo)

Record numbers. Don't proceed without them.

### 3 — Diagnose

Common patterns and the smell they leave:

**N+1 queries** — code makes one query, then loops and queries per result. Smell: many identical queries in the log, total query count ~= result count.
- Fix: `IN (...)` batch, `JOIN`, or a DataLoader-style batcher.

**Sequential awaits when parallel works** — `for (const id of ids) { await fetch(id) }` is N round-trips. Smell: latency scales with N.
- Fix: `await Promise.all(ids.map(fetch))`. Cap concurrency with `p-limit` if needed.

**Re-renders in React** — child renders even when its props haven't changed. Smell: React Profiler shows wide flame.
- Fix: lift state, `useMemo` for derived values, `React.memo` for components, stable references for callbacks. **Don't sprinkle these — find the actual culprit first.**

**Bundle bloat** — initial JS payload is 800kB+. Smell: Lighthouse LCP poor, FCP fine.
- Fix: dynamic `import()` for non-critical routes, tree-shake (look for `import * as X`), check for whole libraries imported for one function (date-fns sub-imports, lodash-es).

**Missing indexes** — sequential scan on a 10M-row table. Smell: `EXPLAIN ANALYZE` shows `Seq Scan` + high actual time.
- Fix: targeted index. Validate with `EXPLAIN ANALYZE` that the plan changed.

**Bad index usage** — index exists but planner skips it. Smell: `EXPLAIN` shows `Filter` after the scan rather than `Index Cond`.
- Fix: usually a type mismatch in the query (`WHERE id = '123'` against integer column), or stale statistics (`ANALYZE`).

**Memory leak** — RSS climbs over hours/days, never plateaus. Smell: process restarts every N days "for hygiene".
- Fix: heap snapshots before/after a workload; look for unbounded caches, retained closures, untracked subscriptions, growing `Map`s indexed by request ID.

**Event-loop lag** — Node app's response times balloon under load even though the work is async. Smell: `clinic doctor` shows lag > 50ms.
- Fix: usually a sync CPU-heavy step (JSON.parse of a huge body, regex, crypto on the request thread) — move to a worker_thread or off the request path.

### 4 — Fix the *one* thing

Apply the smallest change that addresses the diagnosis. Don't pre-optimize the next thing you "noticed while you were there".

### 5 — Measure after

Same tool, same scenario, same load. Record the new number. Report the delta in absolute and relative terms:

```
Before: p95 endpoint latency 1240ms
After:  p95 endpoint latency 180ms  (-85%)
Trade-off: added an in-memory index that uses ~40MB heap per worker.
```

If the after number didn't move, **revert**. The fix wasn't the right one.

### 6 — Lock the win

Add a regression guard:

- Backend: a perf test in CI (`autocannon` or `k6` with a p95 threshold)
- Frontend: bundle-size budget (`@bundle-analyzer/cli`, Next.js `experimental.bundlePagesRouterDependencies`, or `bundlewatch`)
- DB: a query that the test suite runs `EXPLAIN ANALYZE` on, asserting the plan uses the new index

Without a guard, the fix decays. The next refactor will un-do it silently.

## Patterns and anti-patterns

✅ **Do**:
- Optimize the hot path. 80% of latency typically lives in 20% of the code. Profile.
- Cache at the *right* layer — closest to the consumer that can tolerate the staleness. Browser, CDN, app-memory, Redis, DB materialized view.
- For DB perf: read the plan. The plan tells the truth; intuition lies.
- Save a copy of the profile/flame chart in the PR — future you will need to know what the world looked like before.

❌ **Don't**:
- Don't add a cache without an invalidation plan. The bugs you create with a stale cache are worse than the latency you save.
- Don't `useMemo` everything in a React component. Memoization itself costs CPU and memory. Profile first.
- Don't switch from REST to GraphQL "for perf". The wire format isn't usually the bottleneck.
- Don't rewrite in Rust/Go/etc. before you've fixed the obvious things in the current stack.
- Don't optimize what runs once. A 10x speedup on a script that runs at deploy time is worth nothing.

## Example invocation

> User: "Our `/api/dashboard` endpoint takes 4 seconds. Make it fast."

1. Get numbers: p95 is 4.2s, p99 is 7s, cache cold. Target: p95 < 500ms.
2. Profile: turn on Postgres `log_min_duration_statement = 100`. Make one request, check the log.
3. Diagnose: 47 queries logged. Looks like N+1 — for each org member, the code fetches their last 5 activities individually.
4. Fix: replace with one batched query joining users + activities + filtering for "last 5 per user" via a window function. Single query, ~20ms.
5. Measure after: p95 down to 180ms. Trade-off: query is longer to read, added a comment explaining the window function.
6. Lock: add a `EXPLAIN ANALYZE` assertion in the integration test that the plan uses the `(user_id, created_at DESC)` index.
7. Report: -96% latency, single query replaces 47, regression test in place.

## See also

- `code-auditor` — to find the N+1 patterns and unbounded loops *before* they're slow
- `schema-architect` — when the fix is an index that the schema is missing
- `refactor-master` — when the perf fix is structural (e.g., pulling I/O out of a hot loop)

