Perf Hunter
Audit TypeScript/Node.js code for performance antipatterns and resource management — places where I/O blocks the event loop, independent operations run sequentially instead of concurrently, queries multiply inside loops, resources leak without cleanup, caches grow without bound, or expensive work sits in hot paths. The goal: every async function is truly non-blocking, every resource has a bounded lifecycle, and every data access pattern scales with the workload.
When to Use
- Reviewing async code for correctness (event loop blocking, sequential awaits)
- Auditing resource lifecycle (unclosed files, connections, streams, database clients)
- Hunting N+1 query patterns in ORM-heavy code (Prisma, TypeORM, Drizzle, MikroORM)
- Checking connection pool and client reuse configuration
- Reviewing code for unbounded in-memory growth before production deployment
- Profiling structurally inefficient patterns in request handlers or hot paths
- Evaluating streaming vs eager data loading for large datasets
Core Principles
The event loop is sacred. Node.js runs on a single-threaded event loop. A synchronous CPU-intensive operation or blocking I/O call in a request handler stalls all concurrent requests. Every I/O operation inside a request path must be non-blocking.
Concurrency is the point — but bounded. Independent async operations should run concurrently. Sequential
awaiton independent calls wastes the concurrency that async provides. UsePromise.all(),Promise.allSettled(), orPromise.race()for independent operations — and bound the fan-out when the collection is unbounded (see §10): pools, sockets, and file descriptors are finite.Batch over loop. One query returning N results is almost always faster than N queries returning 1 result each. The network round-trip dominates, not the query complexity.
Resources must be bounded. Every cache needs eviction, every pool needs a size limit, every buffer needs backpressure. Unbounded growth is a slow memory leak that only manifests under production load.
Stream over buffer. Don't load a million-row result set into an array to iterate over it once. Node.js streams, async iterators, and database cursors exist for a reason — use them for large datasets.
Pool and reuse. Creating connections, HTTP clients, and database clients is expensive. Pool them at the application level and reuse across requests.
Profile before optimizing. Not every hot path needs optimization. Flag patterns that are structurally inefficient (N+1, blocking I/O, unbounded caches) rather than speculating about micro-performance.
Canonical Exceptions
Not every finding requires action. Document these but do not flag as "must-fix":
| Pattern | When Acceptable |
|---|---|
Sync fs operations at startup |
readFileSync in module init or config loading (not in request path) |
| Sequential awaits | When call B depends on result of call A |
Array.from() materialization |
When random access, .length, or multiple iterations are needed |
| Per-request client creation | One-off scripts, CLIs, or test utilities |
Unbounded Map at module level |
Registries populated once at startup with bounded key space |
JSON.parse() per request |
Normal request body parsing — not a hot path concern |
crypto.randomUUID() |
Fast enough for request-level IDs |
What to Hunt
1. Event Loop Blocking
Synchronous I/O or CPU-intensive operations in request handlers, middleware, or any async context that block the event loop.
Signals:
fs.readFileSync(),fs.writeFileSync(),fs.existsSync()in request-handling code (not startup)child_process.execSync()orchild_process.spawnSync()in async context- CPU-intensive operations without offloading: large JSON parsing, image processing, cryptographic operations (non- streaming), complex regex on large strings
whileloops polling a condition synchronouslyArray.sort()orArray.filter()on very large arrays (10K+ items) in request handlers- Synchronous
require()calls in hot paths (dynamic imports in request handlers)
Action: Replace with async equivalents: fs.promises.readFile(), child_process.exec() (promisified),
crypto.subtle for web crypto, worker_threads for CPU-bound work. For unavoidable sync operations, offload to
a worker thread via worker_threads or piscina.
2. Sequential Awaits Where Promise.all Would Work
Multiple independent await calls made sequentially when they could run concurrently.
Signals:
- Two or more consecutive
awaitcalls where neither depends on the result of the other:const a = await fetchA(); const b = await fetchB(); const c = await fetchC(); - Especially when the fetched data is used only after all calls complete
for...ofloops withawaitinside where iterations are independent- Sequential database queries that could be parallelized
Action: Use Promise.all() or Promise.allSettled() for independent async operations:
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
Use Promise.allSettled() when partial failure should not reject the aggregate. Keep sequential for dependent calls.
3. N+1 Query Patterns
Code that issues one database query per item in a loop instead of a single batch query.
Signals:
for (const item of items) { await repo.findById(item.id) }— loop with individual queriesfor (const user of users) { user.orders = await getOrders(user.id) }— lazy loading in a loop- Prisma:
findUnique()inside a loop instead offindMany({ where: { id: { in: ids } } }) - TypeORM: individual
.findOneBy()calls in a loop instead of.findBy({ id: In(ids) }) - GraphQL resolvers that issue one query per field resolution (dataloader missing)
.map()withasynccallback +Promise.all()wrapping individual queries (still N queries, just concurrent)
Action: Replace with batch queries (WHERE id IN (...)), use ORM eager loading (Prisma include, TypeORM
relations, MikroORM populate), or implement a DataLoader pattern for GraphQL. Note: Promise.all(items.map(i => query(i))) reduces latency but still issues N queries — batch is preferred.
4. Unclosed Resources
Files, connections, streams, or client objects opened without proper cleanup, risking resource leaks under error conditions.
Signals:
fs.createReadStream()without.destroy()orpipeline()error handlingnew AbortController()created but never.abort()-ed on cleanup- Database connections acquired manually (
pool.connect()) withouttry/finallyorusing fetch()responses where.bodyis not consumed or cancelled (keeps connection open)- Event listeners added without corresponding
removeListener()orAbortSignalcleanup setInterval()/setTimeout()withoutclearInterval()/clearTimeout()on cleanup
(Unclosed servers/clients in test files are test-hunter's finding — its flaky-test category owns in-test resource cleanup; this hunter's scans exclude test files.)
Action: Use try/finally, using (TC39 Explicit Resource Management), stream.pipeline(), or AbortController
for cleanup. For resources that span scopes, register cleanup in shutdown handlers. Prefer using declarations where
supported by the runtime.
5. Unbounded In-Memory Growth
Collections that grow without limit: Maps used as caches without eviction, arrays that accumulate indefinitely, or queues without backpressure.
Signals:
const cache = new Map<string, T>()populated in a function called per-request with no eviction- Module-level
MaporSetthat only grows (.set()/.add()without.delete()) - Arrays used as event logs or buffers that
.push()indefinitely WeakReforFinalizationRegistrymisuse (not actually preventing accumulation)- LRU cache without size limit
- In-memory session stores without TTL
Action: Use bounded caches with TTL (e.g., lru-cache, @isaacs/ttlcache, or Map with periodic eviction).
For buffers, enforce a maximum size with backpressure. For event logs, use rotation or external storage. Use
WeakMap / WeakRef when caching objects that should be GC'd when the key is.
6. Eager Materialization of Large Datasets
Loading entire result sets or files into memory as arrays when streaming or incremental processing would suffice.
Signals:
const rows = await db.query("SELECT * FROM large_table")loading unbounded result setsconst content = await fs.readFile(path, 'utf-8')on files that could be multi-MBArray.from(asyncIterable)orfor awaitcollecting into an array.toArray()on database cursors before processingJSON.parse(await readFile(largePath))loading large JSON into memoryresponse.json()on API responses with unbounded array payloads
Action: Use database cursors, LIMIT/OFFSET pagination, or streaming queries. Use fs.createReadStream()
with line-by-line processing. Use async iterators and process items incrementally. For JSON, consider a streaming
parser (stream-json — current and ships TypeScript declarations; avoid the long-unmaintained JSONStream).
7. Missing Connection Pool Configuration
Database or HTTP connections created per-request without pooling, or pool defaults that are too small/large for the workload.
Signals:
new PrismaClient()created inside request handlers instead of shared application-level instancenew Pool()(pg) withoutmax,idleTimeoutMillis, orconnectionTimeoutMillisconfigurationcreateConnection()(TypeORM) called per-request instead of using a shared connection poolfetch()oraxios.create()creating new instances per request (HTTP keep-alive not reused)new Redis()(ioredis) per request instead of shared client- No pool configuration tuning (relying on library defaults without understanding workload needs)
Action: Share database clients / pools at application scope (singleton or DI). Configure pool limits:
max connections, idle timeout, connection timeout. Reuse HTTP clients across requests (keep-alive). Use
a single Redis client instance shared across the application.
8. Expensive Operations in Hot Paths
Computationally expensive or I/O operations placed inside tight loops, request handlers, or frequently-called functions without caching or batching.
Signals:
new RegExp(pattern)inside a function called per-request (should be module-level constant)JSON.parse()/JSON.stringify()on the same data multiple times within one requestpath.resolve()orpath.join()withprocess.cwd()called repeatedly (should be cached)- Dynamic
import()in a hot path (should be top-level import or cached) crypto.pbkdf2Sync()or other expensive crypto in request thread (use async variant or worker)- Repeated file system stats on the same path within one request
- String concatenation in a loop building large strings (use array +
.join()) Date.now()/new Date()called many times where a single timestamp per request suffices
Action: Hoist expensive operations out of loops. Pre-compile regexes at module level. Cache expensive computations with appropriate TTL. Use batching for I/O operations. Offload CPU-intensive crypto to worker threads or async APIs.
9. Memory Leak Patterns
Event listener accumulation, closure captures, and reference retention that prevent garbage collection.
Signals:
emitter.on('event', handler)without correspondingoff()— especially in request handlers or class instances that are created/destroyed frequentlyEventEmitterMaxListenersExceededWarningsuppressed withsetMaxListeners(0)instead of fixing the leak- Closures capturing large objects (request context, database results) in long-lived callbacks
- Global
Mapkeyed by request-specific identifiers without cleanup setInterval()registered in constructors without cleanup in destructors- Promises that never resolve/reject, holding references indefinitely
Action: Always pair on() with off() or use { once: true }. Use AbortSignal for listener lifecycle.
Avoid capturing request-scoped data in long-lived closures. Use WeakMap for caches keyed by objects. Clear
intervals in cleanup/destroy methods.
10. Unbounded Concurrency
Concurrent fan-out over collections with no size limit — the mirror image of sequential awaits. Promise.all()
over thousands of items opens thousands of simultaneous connections, exhausting pools, sockets, and file
descriptors.
Signals:
Promise.all(items.map(async item => ...))whereitemsis unbounded (user-supplied lists, full table reads)- Concurrent fetch/query fan-out with no concurrency limiter and no batching
- Recursive async spawning (crawlers, tree walkers) without a semaphore
- Fan-out against a pooled resource where the fan-out width exceeds the pool size
Action: Bound the fan-out: p-limit / p-map with a concurrency option, or process in fixed-size batches.
Match the bound to the downstream resource (pool size, rate limit).
Audit Workflow
Phase 1: Identify Runtime Architecture
Resolve audit surface. The prompt may specify the scope as:
- Diff: files changed relative to the base branch — committed, staged, unstaged, and untracked
- Path: specific files, folders, or layers
- Codebase: the entire project (the default when unspecified; set
SCOPE=.)
Party mode: when the orchestrator supplies a scope snapshot (a resolved file list), use it verbatim and do not re-resolve. The resolution below applies to standalone runs only.
For diff mode, resolve fail-closed:
BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/@@') if [ -z "$BASE" ]; then for b in origin/main origin/master main master; do git rev-parse -q --verify "$b" >/dev/null && BASE=$b && break done fi # If BASE is still empty: STOP. Ask for an explicit base. Do not continue. SCOPE=$( { git diff --name-only --diff-filter=d "$BASE"...HEAD; git diff --name-only --diff-filter=d HEAD; git ls-files --others --exclude-standard; } | sort -u ) DELETED=$( { git diff --name-only --diff-filter=D "$BASE"...HEAD; git diff --name-only --diff-filter=D HEAD; } | sort -u )If
$SCOPEis empty, run no scans: write the report with "Audit completed: 0 findings — empty diff scope", listing$DELETEDunder "Deleted in diff" if non-empty, and stop. If the resolved surface exceeds what can be read within the context budget, report the file count and ask to narrow or chunk.Two surfaces. Findings are reported only against the target scope (
$SCOPE) — every finding anchors (file:line) there. Related files may still be read as context: N+1 call-chain tracing and pool/client sharing checks follow code wherever it lives, even outside the scope.Determine the runtime and framework (Express, Fastify, NestJS, Next.js, serverless, etc.) and whether async patterns are prevalent.
Note the ORM / database client in use (Prisma, TypeORM, Drizzle, Knex, pg, etc.) and caching strategy.
Phase 2: Scan for Performance Signals
Run every scan against the target scope (SCOPE=. in codebase mode).
# Production-scan exclusions: dependencies, build output, generated code, tests
EXCLUDE='--glob !**/node_modules/** --glob !**/dist/** --glob !**/*.generated.* --glob !**/__generated__/** --glob !**/*.g.ts --glob !**/generated/** --glob !**/*.test.* --glob !**/*.spec.* --glob !**/*.e2e.* --glob !**/__tests__/**'
# Event loop blocking
rg 'readFileSync|writeFileSync|existsSync|accessSync|mkdirSync|readdirSync' --type ts $EXCLUDE -- $SCOPE
rg 'execSync|spawnSync' --type ts $EXCLUDE -- $SCOPE
# Sequential awaits (assignment-form; also inspect any two adjacent await lines)
rg -U 'await[^\n]*\n\s*(const |let |var )?\w+\s*=?\s*await' --type ts $EXCLUDE -- $SCOPE
# N+1 patterns (loop with await/query inside)
rg -U 'for\s*\(.*of\s+\w+\)\s*\{[^}]*await' --type ts $EXCLUDE -- $SCOPE
rg 'findUnique\(|findOneBy\(|findOne\(' --type ts $EXCLUDE -- $SCOPE
# Unbounded concurrency fan-out
rg 'Promise\.all(Settled)?\(\s*\w+\.map\(' --type ts $EXCLUDE -- $SCOPE
# Unclosed resources
rg 'createReadStream|createWriteStream' --type ts $EXCLUDE -- $SCOPE
rg 'pool\.connect\(' --type ts $EXCLUDE -- $SCOPE
# Unbounded caches
rg 'new Map\(\)|new Set\(\)' --type ts $EXCLUDE -- $SCOPE
rg --pcre2 'cache\s*[:=]\s*new\s+Map' --type ts $EXCLUDE -- $SCOPE
# Eager materialization
rg '\.toArray\(\)' --type ts $EXCLUDE -- $SCOPE
rg 'readFile\(' --type ts $EXCLUDE -- $SCOPE
# Connection pool configuration
rg 'new Pool\(|PrismaClient\(|createConnection\(|createPool\(' --type ts $EXCLUDE -- $SCOPE
# Hot path operations
rg 'new RegExp\(' --type ts $EXCLUDE -- $SCOPE
rg 'JSON\.parse|JSON\.stringify' --type ts $EXCLUDE -- $SCOPE
# Memory leak patterns
rg '\.on\(|\.addEventListener\(' --type ts $EXCLUDE -- $SCOPE
rg 'setInterval\(' --type ts $EXCLUDE -- $SCOPE
rg 'setMaxListeners\(0\)' --type ts $EXCLUDE -- $SCOPE
Phase 3: Evaluate Async Correctness
For each request handler or middleware:
- Check for synchronous file system / child process calls.
- Identify sequential awaits that could be gathered.
- Verify CPU-intensive operations are offloaded to workers.
Phase 4: Evaluate Resource Lifecycle
For each opened resource:
- Verify cleanup path —
try/finally,using,pipeline(),AbortController. - Check that database connections are returned to pools.
- Identify resources that span scopes without explicit lifecycle management.
- Check event listener registration/deregistration balance.
Phase 5: Evaluate Data Access Patterns
- Check for N+1 patterns in loops that issue queries.
- Verify ORM eager loading where lazy loading would cause N+1.
- Check for missing DataLoader in GraphQL resolvers.
- Verify batch operation opportunities.
Phase 6: Produce Report
Output Format
Save as YYYY-MM-DD-perf-hunter-audit-{model-name}.md — {model-name} is the executing model's short name (e.g.
fable-5) — in the project's docs folder (or project root if no docs folder exists). If the caller specifies an
output path (e.g. the party-hunter orchestrator), it overrides this default.
Severity levels, used for per-finding labels and the Recommendations grouping:
- Critical — exploitable now, causes data loss, or breaks behavior on production paths.
- High — a defect with likely user-visible, security, or reliability impact if left unaddressed.
- Medium — correctness or maintainability risk without imminent impact.
- Low — hygiene; no behavioral risk.
# Perf Hunter Audit — {date}
## Scope
- Surface: {diff / path / codebase}
- Files: {count or list}
- Runtime: {Node.js / Deno / Bun}
- Framework: {Express / Fastify / NestJS / Next.js / etc.}
- ORM: {Prisma / TypeORM / Drizzle / none}
- Exclusions: {list}
- {Deleted in diff: {list} — only for diff scope with deletions}
- Audit completed: {N} findings
## Runtime Architecture
- Framework: {Express / Fastify / NestJS / Next.js / serverless / etc.}
- Async I/O: {native fetch / axios / node-fetch / undici / etc.}
- Database client: {Prisma / TypeORM / Drizzle / pg / etc.}
- Caching: {Redis / in-memory Map / lru-cache / none}
## Findings
### Event Loop Blocking
| # | Location | Blocking Call | Async Equivalent | Severity |
| - | -------- | ------------- | ---------------- | -------- |
| 1 | file:line | `readFileSync()` in handler | `fs.promises.readFile()` | High |
### Sequential Awaits
| # | Location | Calls | Independent? | Estimated Speedup | Action |
| - | -------- | ----- | ------------ | ----------------- | ------ |
| 1 | file:line | `fetchA`, `fetchB`, `fetchC` | Yes | ~3x | Use `Promise.all()` |
### N+1 Query Patterns
| # | Location | Loop | Query per Iteration | Batch Alternative |
| - | -------- | ---- | ------------------- | ----------------- |
| 1 | file:line | `for (const user of users)` | `getOrders(user.id)` | `getOrdersBatch(userIds)` |
### Unclosed Resources
| # | Location | Resource | Cleanup | Action |
| - | -------- | -------- | ------- | ------ |
| 1 | file:line | `createReadStream()` | No error cleanup | Use `stream.pipeline()` |
### Unbounded In-Memory Growth
| # | Location | Collection | Growth Pattern | Action |
| - | -------- | ---------- | -------------- | ------ |
| 1 | file:line | `cache = new Map()` | Module-level, never evicted | Use `lru-cache` with maxSize |
### Eager Materialization
| # | Location | Operation | Data Size | Action |
| - | -------- | --------- | --------- | ------ |
| 1 | file:line | `db.query().toArray()` | Unbounded | Use cursor / streaming |
### Missing Connection Pool Configuration
| # | Location | Client/Pool | Issue | Action |
| - | -------- | ----------- | ----- | ------ |
| 1 | file:line | `new PrismaClient()` | Per-request creation | Share at application scope |
### Expensive Operations in Hot Paths
| # | Location | Operation | Frequency | Action |
| - | -------- | --------- | --------- | ------ |
| 1 | file:line | `new RegExp()` in handler | Per-request | Hoist to module level |
### Memory Leak Patterns
| # | Location | Pattern | Action |
| - | -------- | ------- | ------ |
| 1 | file:line | `.on('data', ...)` without `.off()` | Clean up on request end |
### Unbounded Concurrency
| # | Location | Fan-Out | Bound | Action |
| - | -------- | ------- | ----- | ------ |
| 1 | file:line | `Promise.all(userIds.map(fetchUser))` | none | Use `p-limit` (e.g. 10) or batch |
## Recommendations (Priority Order)
1. **Critical**: {event loop blocking or unbounded fan-out already exhausting production resources}
2. **High**: {event loop blocking in request path, N+1 in critical paths, unclosed resources, unbounded caches, unbounded concurrency}
3. **Medium**: {sequential awaits, missing pool configuration, eager materialization of large datasets}
4. **Low**: {hot path optimizations, memory leak patterns, streaming conversions for moderate datasets}
Operating Constraints
- No code edits. This skill produces an audit report only. Implementation is a separate step.
- No empty finding sections. Include only categories with findings. Omit a heading, table, or list entirely when it would contain zero items — do not include empty tables, placeholder subsections, or negative statements like "no dead exports", "none found", or "no issues". Execution status is exempt: the "Audit completed: N findings" line in the Scope section is always present, even at zero findings.
- Scope: performance and resource management only. If a finding doesn't answer "does this code waste resources or block unnecessarily?", it belongs to another hunter — do not flag it here.
- Boundary with simplicity-hunter: simplicity-hunter flags unnecessary abstractions and dead code from a structural perspective. Perf-hunter flags patterns that are structurally correct but operationally wasteful (blocking I/O, N+1, unbounded caches). If the code is over-engineered, it's simplicity. If it's under-optimized, it's perf.
- Evidence required. Every finding must cite
file/path.ts:linewith the exact code. - Structural, not speculative. Flag patterns that are structurally inefficient (proven antipatterns like N+1,
blocking I/O in request handlers), not micro-optimizations based on speculation. Don't flag
Array.map()vsforloop for small bounded datasets. - Runtime awareness. Some patterns only matter in request-handling contexts (blocking I/O, sequential awaits). Startup-time sync operations are generally acceptable. Note the context and don't flag startup-only code as request path problems.