hunter-party-ts/perf-hunter-ts/SKILL.md
Audit TypeScript/Node.js code for performance antipatterns and resource management issues — event loop blocking, sequential awaits, N+1 queries, unclosed resources, unbounded caches, eager materialization, missing connection pooling, and expensive operations in hot paths. Use when: reviewing async correctness, auditing resource lifecycle, hunting N+1 query patterns, checking connection pool configuration, or profiling structurally inefficient code.
npx skillsauth add skyosev/agent-skills perf-hunter-tsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
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. Independent async operations should run concurrently. Sequential await on
independent calls wastes the concurrency that async provides. Use Promise.all(), Promise.allSettled(), or
Promise.race() for independent operations.
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.
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 |
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() or child_process.spawnSync() in async contextwhile loops polling a condition synchronouslyArray.sort() or Array.filter() on very large arrays (10K+ items) in request handlersrequire() 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.
Multiple independent await calls made sequentially when they could run concurrently.
Signals:
await calls where neither depends on the result of the other:
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();
for...of loops with await inside where iterations are independentAction: 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.
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 loopfindUnique() inside a loop instead of findMany({ where: { id: { in: ids } } }).findOneBy() calls in a loop instead of .findBy({ id: In(ids) }).map() with async callback + 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.
Files, connections, streams, or client objects opened without proper cleanup, risking resource leaks under error conditions.
Signals:
fs.createReadStream() without .destroy() or pipeline() error handlingnew AbortController() created but never .abort()-ed on cleanuppool.connect()) without try/finally or usingfetch() responses where .body is not consumed or cancelled (keeps connection open)removeListener() or AbortSignal cleanupsetInterval() / setTimeout() without clearInterval() / clearTimeout() on cleanupclose() in afterAllAction: 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.
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 evictionMap or Set that only grows (.set() / .add() without .delete()).push() indefinitelyWeakRef or FinalizationRegistry misuse (not actually preventing accumulation)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.
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) or for await collecting 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 payloadsAction: 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 streaming
parsers (JSONStream, stream-json).
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) without max, idleTimeoutMillis, or connectionTimeoutMillis configurationcreateConnection() (TypeORM) called per-request instead of using a shared connection poolfetch() or axios.create() creating new instances per request (HTTP keep-alive not reused)new Redis() (ioredis) per request instead of shared clientAction: 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.
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() or path.join() with process.cwd() called repeatedly (should be cached)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).join())Date.now() / new Date() called many times where a single timestamp per request sufficesAction: 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.
Event listener accumulation, closure captures, and reference retention that prevent garbage collection.
Signals:
emitter.on('event', handler) without corresponding off() — especially in request handlers or class instances
that are created/destroyed frequentlyEventEmitter MaxListenersExceededWarning suppressed with setMaxListeners(0) instead of fixing the leakMap keyed by request-specific identifiers without cleanupsetInterval() registered in constructors without cleanup in destructorsAction: 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.
main/master)BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo main)
SCOPE=$(git diff --name-only $(git merge-base HEAD $BASE)...HEAD)
Constrain all subsequent scans to the resolved surface.EXCLUDE='--glob !**/*.test.* --glob !**/*.spec.* --glob !**/node_modules/** --glob !**/dist/**'
# Event loop blocking
rg 'readFileSync|writeFileSync|existsSync|accessSync|mkdirSync|readdirSync' --type ts $EXCLUDE
rg 'execSync|spawnSync' --type ts $EXCLUDE
# Sequential awaits
rg -U 'await.*\n\s*\w+\s*=\s*await' --type ts $EXCLUDE
# N+1 patterns (loop with await/query inside)
rg -U 'for\s*\(.*of\s+\w+\)\s*\{[^}]*await' --type ts $EXCLUDE
rg 'findUnique\(|findOneBy\(|findOne\(' --type ts $EXCLUDE
# Unclosed resources
rg 'createReadStream|createWriteStream' --type ts $EXCLUDE
rg 'pool\.connect\(' --type ts $EXCLUDE
# Unbounded caches
rg 'new Map\(\)|new Set\(\)' --type ts $EXCLUDE
rg --pcre2 'cache\s*[:=]\s*new\s+Map' --type ts $EXCLUDE
# Eager materialization
rg '\.toArray\(\)' --type ts $EXCLUDE
rg 'readFile\(' --type ts $EXCLUDE
# Connection pool configuration
rg 'new Pool\(|PrismaClient\(|createConnection\(|createPool\(' --type ts $EXCLUDE
# Hot path operations
rg 'new RegExp\(' --type ts $EXCLUDE
rg 'JSON\.parse|JSON\.stringify' --type ts $EXCLUDE
# Memory leak patterns
rg '\.on\(|\.addEventListener\(' --type ts $EXCLUDE
rg 'setInterval\(' --type ts $EXCLUDE
rg 'setMaxListeners\(0\)' --type ts $EXCLUDE
For each request handler or middleware:
For each opened resource:
try/finally, using, pipeline(), AbortController.Save as YYYY-MM-DD-perf-hunter-audit-{$LLM-name}.md in the project's docs folder (or project root if no docs
folder exists).
# 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}
## 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 |
## Recommendations (Priority Order)
1. **Must-fix**: {event loop blocking in request path, N+1 in critical paths, unclosed resources, unbounded caches}
2. **Should-fix**: {sequential awaits, missing pool configuration, eager materialization of large datasets}
3. **Consider**: {hot path optimizations, memory leak patterns, streaming conversions for moderate datasets}
file/path.ts:line with the exact code.Array.map() vs
for loop for small bounded datasets.development
Transforms vague feature ideas into precise, codebase-grounded technical requirements. Use when requirements are ambiguous/incomplete, the user struggles to describe behavior, terminology is unclear, or multiple concepts are mixed. Output is a requirements spec—NOT an implementation plan.
tools
Audit TypeScript type definitions for design debt — duplicated shapes, missing derivations, over-engineered generics, under-constrained type parameters, reinvented utility types, and disorganized type architecture. Type structure and maintainability, not type enforcement. Use when: reviewing type definitions for maintainability, reducing type duplication, simplifying over-engineered type-level logic, or reorganizing type architecture after growth.
development
Audit TypeScript test code for quality gaps — missing coverage on critical paths, brittle tests coupled to implementation, over-mocking, assertion-free tests, missing edge cases, and duplicated test setup. Focuses on test effectiveness, not production code structure. Use when: reviewing TypeScript test suites for reliability, reducing false-positive test failures, improving coverage of critical business logic, or cleaning up test debt.
tools
Audit TypeScript class and interface design for SOLID violations — god classes, rigid extension points, broken substitutability, fat interfaces, and concrete dependency chains. Focuses on responsibility assignment and abstraction fitness. Use when: reviewing class hierarchies, preparing for extension with new variants, reducing coupling between services, or improving testability of class-heavy code.