Sub-100ms APIs: A Practical Guide to Node.js Performance Optimization
Profile before you optimize
The most expensive performance mistake is optimizing the wrong thing confidently. Before changing anything, I want a flame graph or, at minimum, timed spans around the suspicious code path. In practice, the culprit in a slow Node.js API endpoint is almost always one of four things, roughly in order of frequency:
- An unindexed or over-fetching database query
- A cache that isn't being hit (or doesn't exist yet)
- Synchronous CPU work blocking the event loop
- N+1 queries from naive relational data fetching
The event loop is the actual bottleneck
Node.js is single-threaded for your application code. A single JSON.parse on a 10MB payload, a synchronous bcrypt.hashSync, or a tight loop over 100,000 items will block every other request on that process for the duration — not just the request that triggered it. The fix is almost always "use the async version" (bcrypt.hash, not hashSync) or, for genuinely CPU-heavy work, offloading to a worker thread or a background job queue so the main event loop stays free to handle I/O.
Query shape matters more than query count
// Fetches every field, including a large "description" blob you don't render in the list view
const products = await Product.find({ category });
// Projects only what the endpoint actually returns
const products = await Product.find({ category })
.select("title price thumbnail slug")
.lean();
The .lean() call matters too — it skips Mongoose's document hydration (change tracking, virtuals, getters/setters) for data you're only going to serialize back to JSON anyway. On list endpoints returning hundreds of documents, this alone is often a double-digit percentage latency win with zero behavior change.
Caching: the 75% database-load reduction
A Redis cache-aside layer in front of read-heavy, infrequently-changing endpoints (product detail, category listings) is usually the single biggest lever available. The pattern is simple — check cache, miss, hit the database, populate cache, return — but the TTL and invalidation strategy is where the real engineering is. Too short a TTL and you're barely caching; too long and admins editing content wonder why their changes "aren't showing up." I default to a short TTL (30–60s) plus explicit invalidation on write, which gets the freshness guarantee of no caching with most of the load reduction of aggressive caching.
Connection pooling you actually configured
Default connection pool sizes are conservative. Under real concurrent load, a pool that's too small means requests queue waiting for a free connection — which looks exactly like a slow database from the outside, but is actually a slow configuration. Sizing the pool to match your expected concurrent request volume (with headroom, not a guess) is a five-minute change that's fixed more "mysterious" latency spikes for me than any query optimization.
The checklist, in order
- Profile first — don't guess
- Project only the fields the response needs, and use
.lean()/equivalent - Cache read-heavy endpoints with a short TTL and explicit invalidation
- Move CPU-bound work off the main event loop
- Size your connection pool for real concurrency, not the default