n an event loop to multiplex I/O operations. Introducing synchronous blocking calls (e.g., sleep, CPU-heavy loops, or synchronous HTTP clients) halts the entire loop, preventing other requests from progressing.
Architecture Decision: Replace all blocking operations with non-blocking equivalents. Use native fetch or established async HTTP clients. Offload CPU-intensive work to worker threads or separate processes when necessary.
import { setTimeout as sleep } from 'timers/promises';
// β Anti-pattern: Synchronous delay blocks the event loop
async function naiveFetch(url: string): Promise<unknown> {
// Simulates blocking I/O or CPU wait
const start = Date.now();
while (Date.now() - start < 2000) { /* busy wait */ }
const res = await fetch(url);
return res.json();
}
// β
Production pattern: Non-blocking async I/O
async function optimizedFetch(url: string): Promise<unknown> {
// Yield control back to the event loop
await sleep(2000);
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
Rationale: timers/promises integrates with the event loop scheduler, allowing other callbacks to execute during the wait. Adding AbortSignal.timeout prevents indefinite hangs, a common cause of connection leaks in production.
Step 2: Eliminate Hot-Path Allocations
Object creation in tight loops triggers frequent garbage collection. Each allocation consumes heap space, and short-lived objects increase generational GC pressure.
Architecture Decision: Hoist static values outside loops. Pre-allocate buffers or arrays when size is known. Use Map or Set for lookups instead of repeated object instantiation.
// β Anti-pattern: Repeated allocation in iteration
function naiveTransform(ids: number[]): string[] {
const results: string[] = [];
for (const id of ids) {
const config = { prefix: 'user_', suffix: '_v1' }; // New object every iteration
results.push(`${config.prefix}${id}${config.suffix}`);
}
return results;
}
// β
Production pattern: Hoisted constants & direct interpolation
function optimizedTransform(ids: number[]): string[] {
const PREFIX = 'user_';
const SUFFIX = '_v1';
const results = new Array<string>(ids.length); // Pre-allocated array
for (let i = 0; i < ids.length; i++) {
results[i] = `${PREFIX}${ids[i]}${SUFFIX}`;
}
return results;
}
Rationale: Pre-allocating the result array avoids dynamic resizing overhead. Hoisting constants removes object creation from the hot path. Direct indexing bypasses Array.prototype.push overhead in performance-critical loops.
Step 3: Enforce Deterministic Resource Cleanup
Network clients, database connections, and file streams hold OS-level resources. Failing to release them exhausts file descriptors, connection pools, or memory handles.
Architecture Decision: Use explicit lifecycle management. TypeScript 5.2+ supports the using keyword for deterministic disposal. Alternatively, wrap resources in try/finally blocks or factory functions that guarantee cleanup.
import { Agent } from 'undici';
// β Anti-pattern: Unclosed HTTP agent
async function naiveRequest(url: string): Promise<unknown> {
const agent = new Agent({ connections: 100 });
const res = await fetch(url, { dispatcher: agent });
return res.json();
// Agent remains open, leaking sockets
}
// β
Production pattern: Deterministic disposal
async function optimizedRequest(url: string): Promise<unknown> {
using agent = new Agent({ connections: 100, bodyTimeout: 3000 });
const res = await fetch(url, { dispatcher: agent });
return res.json();
// Agent automatically closes when scope exits
}
Rationale: The using keyword (TC39 Explicit Resource Management) guarantees Symbol.dispose is called, even if exceptions occur. This eliminates a entire class of connection leaks. Setting bodyTimeout prevents slowloris-style resource starvation.
Step 4: Implement Bounded Memoization
Deterministic functions called repeatedly with identical arguments waste CPU cycles. Unbounded caching, however, causes memory leaks.
Architecture Decision: Use a Least Recently Used (LRU) cache with a strict size limit and optional TTL. Invalidate explicitly when underlying data changes.
import { LRUCache } from 'lru-cache';
const computeCache = new LRUCache<string, number>({
max: 500,
ttl: 1000 * 60 * 5, // 5 minutes
allowStale: false
});
function naiveSum(n: number): number {
let total = 0;
for (let i = 0; i < n; i++) total += i;
return total;
}
function optimizedSum(n: number): number {
const key = `sum:${n}`;
const cached = computeCache.get(key);
if (cached !== undefined) return cached;
let total = 0;
for (let i = 0; i < n; i++) total += i;
computeCache.set(key, total);
return total;
}
Rationale: lru-cache provides O(1) lookups with automatic eviction. The ttl prevents stale data from persisting indefinitely. allowStale: false ensures consistency in read-heavy workloads. For mathematical sequences like summation, consider closed-form formulas (n * (n - 1) / 2) to eliminate loops entirely.
Pitfall Guide
1. Mixing Synchronous and Asynchronous Code in the Same Call Stack
Explanation: Developers often call synchronous utilities inside async handlers, assuming the runtime will handle scheduling. This blocks the event loop, causing request queuing and timeout cascades.
Fix: Audit all dependencies. Replace sync I/O, regex operations on large strings, and CPU loops with async equivalents or offload to worker threads. Use --trace-sync-io in Node.js to detect blocking calls.
2. Over-Allocation in Hot Paths
Explanation: Creating objects, arrays, or strings inside loops that execute thousands of times per second triggers generational garbage collection. GC pauses introduce tail latency that monitoring tools often misattribute to network or database issues.
Fix: Pre-allocate collections. Hoist constants. Use typed arrays (Uint8Array, Float64Array) for numeric data. Profile with --heapsnapshot to identify allocation hotspots.
3. Connection Pool Starvation
Explanation: Applications that create new HTTP clients or database connections per request exhaust OS file descriptors or database max_connections limits. This manifests as ECONNREFUSED or too many open files errors under load.
Fix: Implement connection pooling with explicit limits. Set idle timeouts and keep-alive intervals. Use undici.Agent or pg.Pool with max and idleTimeoutMillis configured. Monitor active vs idle connections in production.
4. Cache Stampedes
Explanation: When a cached value expires, multiple concurrent requests simultaneously compute the same result, overwhelming the backend. This is known as the thundering herd problem.
Fix: Implement request coalescing or promise deduplication. Use lru-cache with allowStale: true during recomputation, or wrap cache misses in a mutex/lock pattern. Consider write-through caching for critical paths.
5. Ignoring Backpressure in Stream Processing
Explanation: Piping data between streams without checking drain events causes memory accumulation. The producer outpaces the consumer, leading to heap exhaustion and OOM crashes.
Fix: Always check stream return values. Use pipeline or finished from node:stream/promises for automatic backpressure handling. Implement rate limiting or chunking for high-throughput data flows.
Explanation: Setting TTLs too high serves stale data during deployments or data mutations. Setting them too low defeats caching benefits, increasing CPU and I/O load.
Fix: Align TTLs with data mutation frequency. Use versioned cache keys (v2:user:123). Implement cache invalidation hooks in write paths. Monitor cache hit ratios and adjust dynamically.
7. Premature Optimization Without Profiling
Explanation: Refactoring code based on intuition rather than metrics often yields negligible gains while introducing complexity. Developers optimize cold paths while hot paths remain untouched.
Fix: Profile first. Use clinic.js, 0x, or built-in --prof flags to identify actual bottlenecks. Optimize only after measuring baseline performance. Document assumptions and validate with load tests.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-frequency deterministic computation | In-memory LRU cache | Eliminates redundant CPU cycles; O(1) lookup | Reduces compute costs by 40β70% |
| Long-running I/O operations | Async/await with AbortSignal | Prevents event loop saturation and indefinite hangs | Lowers timeout-related infrastructure waste |
| Multi-tenant connection sharing | Pooled clients with idle timeouts | Prevents file descriptor exhaustion and DB limits | Avoids scaling costs from connection leaks |
| Real-time data with frequent mutations | Short TTL + write-through invalidation | Balances freshness with performance | Minimizes stale data incidents without CPU overhead |
| CPU-heavy transformations | Worker threads or separate microservice | Isolates blocking work from request handling | Improves tail latency by 60β90% |
Configuration Template
// src/infrastructure/performance.ts
import { LRUCache } from 'lru-cache';
import { Agent } from 'undici';
export const httpAgent = new Agent({
connections: 50,
pipelining: 0,
bodyTimeout: 5000,
headersTimeout: 3000,
keepAliveTimeout: 4000,
keepAliveMaxTimeout: 10000
});
export const computationCache = new LRUCache<string, unknown>({
max: 1000,
ttl: 1000 * 60 * 10,
allowStale: false,
updateAgeOnGet: true,
dispose: (value, key) => {
// Optional: log eviction metrics or clean up references
console.debug(`Cache evicted: ${key}`);
}
});
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return promise.finally(() => clearTimeout(timer)).then(
(res) => res,
(err) => {
if (err.name === 'AbortError') {
throw new Error(`Operation timed out after ${ms}ms`);
}
throw err;
}
);
}
Quick Start Guide
- Install dependencies:
npm install lru-cache undici
- Replace blocking calls: Audit your codebase for synchronous delays, CPU loops, or sync HTTP clients. Substitute with
async/await and AbortSignal.timeout.
- Wrap resources: Apply the
using keyword or try/finally blocks to all network clients, database connections, and file handles. Verify disposal with --trace-uncaught-exceptions.
- Add bounded caching: Import
computationCache from the template. Wrap deterministic functions with cache lookup/set logic. Set max and ttl based on your data mutation frequency.
- Validate with load testing: Run
autocannon or k6 against your endpoints. Monitor event loop utilization, heap allocation rate, and active connections. Iterate until metrics align with the optimized baseline.