Your Node.js API Is Slow — But CPU Looks Fine. Here’s the Real Bottleneck
Your Node.js API Is Slow — But CPU Looks Fine. Here’s the Real Bottleneck
CPU is the first metric most of us reach for when an API slows down.
- High CPU usually means you’re compute-bound.
- Low CPU usually gets interpreted as “the service is fine.”
In Node.js, that second conclusion is often wrong.
You can easily see:
- 20% CPU usage
- stable memory
- no errors
…and still ship an API whose p95 jumps from tens of milliseconds to hundreds.
The reason is simple: Node.js is frequently latency-bound, not CPU-bound. The real limiter is how quickly the process can accept work, schedule it, and resume it—which is ultimately about event loop availability and queueing.
The Problem
A common production symptom set looks like this:
- p95 latency jumps from ~50ms to ~800ms
- CPU stays under ~30%
- memory is stable
- the service doesn’t crash
From a host-level dashboard, everything looks healthy.
But users still experience slow requests.
When this happens, the debugging trap is staring at CPU charts and waiting for a spike that never comes.
Why It Happens
Node.js performance is constrained by how available the event loop is to run callbacks and by how quickly the system can drain queues that form when something gets slower.
Key insight
In Node.js, many latency incidents are caused by waiting and queue buildup, not by raw compute.
In practice, slow APIs with low CPU typically come from one (or more) of the following.
1) Event loop delay (lag)
The event loop is where your JavaScript runs. If it gets delayed—even briefly—everything else backs up behind it:
- incoming requests wait longer before their handler runs
- timers fire late
- completed I/O can’t be processed until the loop comes back
A lightweight way to observe event loop delay is to schedule work “as soon as possible” and measure how late it runs:
setInterval(() => {
const start = Date.now();
setTimeout(() => {
const lagMs = Date.now() - start;
console.log("Event loop lag (ms):", lagMs);
}, 0);
}, 1000);
Rough guidance (highly workload dependent):
- 5–10ms: typically fine
- ~50ms: you’ll start to see tail latency effects
- 200ms+: the process is effectively congested
Important: event loop delay can rise while CPU stays low.
Common causes include:
- synchronous/blocking JavaScript in request paths
- large JSON serialization/parsing on the main thread
- heavy crypto/compression work
- long microtask chains (for example, patterns that continuously schedule promise continuations)
If you want a more production-grade metric, Node exposes event loop delay and utilization via node:perf_hooks.
2) Latency and CPU are measuring different things
CPU answers: “How much work did the machine do?”
Latency answers: “How long did the request take?”
In typical Node.js services, request latency is dominated by time spent waiting on:
- databases
- caches
- external HTTP services
- the libuv threadpool (for filesystem, DNS, some crypto, etc.)
So it’s completely possible to have:
- low CPU
- high p95/p99 latency
…because the process isn’t busy—it’s stuck waiting while queues grow.
3) Slow downstream dependencies (and accidental serialization)
Your endpoint’s response time is bounded by its slowest dependency.
Example:
app.get("/user/:id", async (req, res) => {
const { id } = req.params;
const user = await db.getUser(id); // ~20ms
const orders = await ordersAPI(id); // ~600ms
res.json({ user, orders });
});
Even if your Node.js code is “fast”, you still shipped a ~600ms endpoint.
Two additional nuances that show up frequently in real systems:
- If calls are independent,
awaiting them sequentially adds unnecessary wall time (usePromise.all). - When a dependency slows down, concurrency and retries can amplify the problem by increasing in-flight work, queue length, and tail latency.
Internal Architecture (the parts that matter for latency)
Here’s the simplified mental model:
flowchart LR
Client --> EventLoop
EventLoop --> CallbackQueue
EventLoop --> ThreadPool
ThreadPool --> ExternalServices
ExternalServices --> ThreadPool
ThreadPool --> CallbackQueue
CallbackQueue --> EventLoop
What to remember:
- The event loop runs JS callbacks (single-threaded).
- The libuv threadpool runs certain blocking operations; it is capacity-limited.
- External services add variable latency that your service can’t control.
- When anything slows down, the callback queue can grow quietly until tail latency explodes.
Example scenario: how a “healthy” host becomes a slow service
Situation
- 1000 concurrent users
- each request does:
- 1 fast DB call
- 1 external API call that drifts to ~500ms
What happens
- Requests arrive faster than the system can complete them.
- The event loop quickly schedules async operations (so CPU stays low).
- The external API slows down.
- In-flight requests accumulate; queues grow.
- The event loop spends more time processing completions and less time starting new work.
- p95/p99 latency climbs sharply.
You end up with a congested system even though:
- CPU is low
- the process isn’t “spinning”
This is queueing, not compute saturation.
Diagram
sequenceDiagram
participant Client
participant Node
participant ExternalAPI
Client->>Node: Request
Node->>ExternalAPI: Call
ExternalAPI-->>Node: Slow response (500ms)
Node-->>Client: Delayed response
Multiply that 500ms dependency delay across thousands of concurrent requests, and you get a service that feels “randomly slow” while host metrics remain deceptively calm.
What to monitor (if you want this to be obvious next time)
If you only take one action: treat queueing signals as first-class metrics.
- event loop delay / event loop utilization
- p95/p99 endpoint latency (per route)
- downstream dependency latency (per dependency)
- number of in-flight requests / request concurrency
- libuv threadpool saturation indicators (work queueing time)
Key takeaways
- Low CPU does not imply a healthy Node.js API.
- Node.js latency incidents often come from waiting and queue buildup.
- Event loop delay is a critical leading indicator.
- Downstream slowness dominates tail latency and can cascade under concurrency.
Final thoughts
Node.js performance debugging works best when you stop asking:
“How busy is my CPU?”
…and start asking:
“Where is the request waiting, and what queue is growing?”
Once you measure that, the bottleneck usually becomes obvious.
Next in the Series
Continue reading: When NOT to Use Node.js — The Decision Framework Senior Engineers Actually Use