Performance · intermediate
CPU is low but requests time out: look beyond compute saturation
CPU utilization is low but client requests still time out, indicating the latency is bounded by something other than compute. This guide frames the symptom as a resource-bound mismatch: time is spent waiting on I/O, locks, connection pools, or downstream deadlines rather than CPU cycles. The first ten minutes are spent measuring wait states and queue depths before considering any code change.
The symptoms
- •Application or service CPU utilization stays below a moderate fraction of cores while p95/p99 latency or upstream timeouts spike.
- •Error rate rises with 504 Gateway Timeout, 499 client closed, or read timeout messages even though the load average or container CPU throttle counters remain low.
- •Threads, goroutines, or async tasks appear parked on I/O, mutex, or condition variables instead of running on CPU.
- •Connection or worker pool wait queues grow while CPU usage is flat, suggesting the worker pool is the actual bottleneck.
- •Downstream call deadlines expire before the local service returns, with no local CPU saturation to explain the delay.
Likely causes
- •Thread, request, or async worker pool is too small relative to incoming concurrency, causing requests to queue behind a tight pool rather than consuming CPU.
- •External dependency such as database, cache, object storage, or third-party API is slow, blocking many in-flight requests on a single shared I/O boundary.
- •Lock contention or single-writer data structure serializes work so threads spin or sleep instead of advancing on CPU.
- •Outbound connection limits, file descriptor exhaustion, or socket send/receive buffer saturation stall network I/O even though the process is otherwise idle on CPU.
- •Client-side or upstream timeout is shorter than realistic end-to-end latency, so requests are cancelled before any compute has a chance to fail.
- •Garbage collection pauses, runtime scheduler stalls, or virtual machine safe-points suspend execution while CPU usage looks low at the sampling interval.
First ten minutes
- 01Capture the time window of the symptom and note CPU, memory, and request rate metrics side by side to confirm the mismatch: low CPU with elevated latency or error rate.
- 02List active waits using the runtime profiler or kernel sampler: off-CPU stacks, thread states, or runtime trace events, and group them by wait reason.
- 03Measure current versus configured limits for thread, worker, and connection pools, plus the depth of any wait queue each pool exposes.
- 04Profile the slowest external dependency with a focused span: database, cache, queue, or third-party API, recording client-perceived latency versus local processing latency.
- 05Compare the configured upstream timeout against observed end-to-end latency under the same load to decide whether the timeout is the limiting factor.
- 06Decide which boundary dominates the wait budget before considering any code or configuration change.
Evidence to collect
- •CPU utilization, throttle counters, and load average for the affected process and host during the incident window.
- •Off-CPU or wait-state profile samples tagged by wait reason such as I/O, lock, park, or sleep, with counts and durations.
- •Pool saturation metrics: active versus maximum threads, worker tasks, database connections, and outbound HTTP connections, including queue wait time.
- •Distributed trace spans for a slow request showing time spent in the application code versus in dependencies and serialization.
- •Network and socket statistics: file descriptor usage, TIME_WAIT count, send and receive buffer occupancy, retransmits, and connect attempts.
- •Error logs filtered for timeout, deadline exceeded, pool exhausted, and connection refused events with timestamps and trace identifiers.
Where to look
- •Runtime and language boundary: thread or goroutine scheduler, garbage collector, and any blocking I/O or synchronization primitive inside the process.
- •Concurrency boundary: configured thread pool, async worker pool, connection pool, and bounded queue feeding the request handler.
- •Network boundary: kernel socket buffers, ephemeral port range, NAT or load balancer connection limits, and TLS handshake time.
- •Dependency boundary: database, cache, message queue, object storage, and any synchronous third-party API call within the request path.
- •Client and edge boundary: load balancer, service mesh, and upstream client timeout and retry configuration relative to service-side latency.
Diagnostic steps
- 01Confirm the mismatch hypothesis by plotting CPU usage against request latency and error rate for the same window; a flat CPU with rising latency supports the non-CPU bound theory.
- 02Aggregate off-CPU stacks by wait reason and rank them by total time to identify the dominant wait class such as I/O, lock, or pool queue.
- 03For each pool, compare active count, configured maximum, and observed queue depth to detect saturation without CPU pressure.
- 04For each external dependency, separate client-side wait time from server processing time using spans and dependency metrics, then identify the slowest link.
- 05Inspect the shortest configured timeout in the request path and compare it to the p99 end-to-end latency under the same load to test the deadline hypothesis.
- 06Check kernel and socket statistics for evidence of connection, descriptor, or buffer exhaustion that would stall I/O independently of CPU.
- 07Synthesize a ranked list of candidate bottlenecks ordered by observed wait time, then design a verification experiment for the top candidate before any change.
Common mistakes
- •Increasing CPU limits, replicas, or instance size when the bottleneck is a bounded pool, a slow dependency, or a too-short timeout, which raises cost without reducing wait time.
- •Treating low CPU as evidence of headroom and adding more concurrency, which deepens queueing and can amplify the timeout storm.
- •Reading thread dumps or stack traces only on-CPU, missing parked threads, blocked I/O, or runtime-specific sleep states that dominate wait time.
- •Assuming the slowest span in a distributed trace is the cause without checking whether the local handler is queuing behind a tight worker pool before it ever runs.
- •Raising client or upstream timeouts without first confirming the upstream can actually complete, which converts timeouts into long, hung requests that exhaust resources.
- •Ignoring safe-points, stop-the-world pauses, or runtime scheduler stalls because aggregate CPU usage at coarse sampling intervals looks low.
Safe fixes
- •If pool wait time dominates, raise the pool size or switch to a bounded async model only after measuring diminishing returns and confirming the downstream can absorb the extra load.
- •If a specific dependency dominates client-perceived latency, add a circuit breaker, cache, or read replica with explicit timeout budgets, and verify with span-level metrics before and after.
- •If lock contention is the top wait reason, reduce critical section size, shard the protected structure, or move work off the hot path, and re-profile to confirm wait time drops.
- •If the configured timeout is shorter than realistic latency, extend it only after increasing local resilience, and verify by observing fewer premature cancellations at the same load.
- •If file descriptors or ephemeral ports are exhausted, raise the limit at the documented kernel or container boundary, monitor for regressions, and confirm via socket statistics.
- •Prefer configuration or topology changes with measurable rollback criteria over speculative code rewrites when the evidence points to a resource ceiling rather than a logic defect.
Prove the fix
- 01Repeat the off-CPU profile at the same load and confirm the previously dominant wait reason drops in rank and total time while CPU usage stays within budget.
- 02Replay the same traffic pattern and observe that p95 and p99 end-to-end latency decrease and the timeout error rate returns to the pre-incident baseline.
- 03Verify that the chosen pool stays below its configured maximum under peak load and that its queue depth does not grow without bound during a sustained test.
- 04Confirm that no new error class appears, such as connection reset or pool exhausted, after the change, by checking the same error log filter used during diagnosis.
- 05Document the bottleneck boundary, the change made, and the regression check so that the next on-call engineer can repeat the verification in under ten minutes.
Prevention and next steps
- •Track wait-class metrics alongside CPU metrics so that low CPU does not silently mask I/O, lock, or pool saturation in dashboards and alerts.
- •Set timeouts on every outbound call with budgets shorter than the upstream client timeout, and alert when the budget is regularly exceeded.
- •Size thread, worker, and connection pools with explicit saturation alerts tied to queue depth, not only to utilization.
- •Periodically replay synthetic load to surface dependency latency drift before it crosses user-visible timeout thresholds.
- •Review client and upstream timeout settings together so a tight client deadline cannot cancel requests the service could still complete.
Safe commands and checks
ps -o pid,pcpu,pmem,etime,cmd -p <pid> top -b -n 1 -p <pid> ss -s ss -tan state time-wait | wc -l cat /proc/<pid>/status | grep -E 'Threads|VmRSS|FDSize' ls /proc/<pid>/fd | wc -l cat /proc/sys/net/ipv4/ip_local_port_range ulimit -n