A .NET service can respond slowly while using little CPU because its ThreadPool workers are blocked rather than computing. Diagnose starvation by correlating request latency, worker-count changes, queued work and completed work, then identifying what occupies the workers. Low CPU alone is not proof.1
The practical consequence is that buying more CPU or increasing concurrency may leave the blocking operation intact. The useful question is which work cannot obtain a worker promptly, and why existing workers cannot return to the pool.
This article follows Microsoft's .NET 9-and-later diagnostic tutorial. Match diagnostic tools and available counters to the deployed runtime.
Distinguish busy processors from unavailable workers
A ThreadPool worker executing synchronous blocking code is unavailable for other work even when the processor is idle. Microsoft's example demonstrates this with synchronous waiting on asynchronous tasks. The runtime responds by increasing worker threads, but requests can experience poor latency while that adjustment occurs.1
A long external operation is not automatically ThreadPool starvation. An asynchronously awaited operation can remain incomplete without continuously occupying a worker. Conversely, synchronous waits can hold workers while the dependency does little or no CPU work in the application process.
This is why a dependency timeout and starvation can coexist. A slow dependency may trigger blocking in application code, and that blocking can then delay unrelated work sharing the pool. Investigation must follow the waiting stack rather than assigning all delay to the external service.
Capture a small, comparable set of measurements
Start with the process identity, runtime version, process start time and the incident interval. Correlate application request rate, latency and failures with runtime observations. Retain the deployment version so later comparisons refer to the same code.
For .NET 9 and later, the current System.Runtime Meter exposes dotnet.thread_pool.thread.count, dotnet.thread_pool.queue.length and dotnet.thread_pool.work_item.count. The diagnostic documentation uses these alongside CPU observations.1, 2
The thread count describes workers; the queue length describes waiting work; completed work needs interpretation over time. Do not treat a cumulative completed-work value as a per-second throughput figure. Preserve the collection interval and the tool's presentation semantics before comparing samples.
The current tools documentation explains that .NET 8 and earlier use older System.Runtime EventCounters instead. A copied dashboard expression using newer names can therefore produce missing data on an older runtime rather than evidence of zero work.2
This article deliberately omits a universal capture command. Match tool version, runtime version, process architecture and access method before using the installed tool's documented syntax.
Recognise the pattern, then test its explanation
Microsoft describes a slowly increasing worker count with CPU well below saturation as a key starvation signal. A large queue and low completion activity often accompany it, but a large queue is not required in every observed sample.1
Avoid converting the tutorial's example thread counts into a universal threshold. They were produced by a particular sample workload. Your own baseline, processor availability and blocking pattern determine what is unusual.
A hypothetical incident might show latency increasing as workers rise while CPU remains modest. That supports a starvation hypothesis. If stacks then show many workers blocked on the same task-waiting call, the evidence becomes much more specific.
A different incident might show CPU saturation with workers actively computing. That points towards a different performance investigation. Similarly, low CPU with no worker growth and no relevant blocking evidence leaves starvation unproven.
Use stacks to locate the blocking boundary
Microsoft recommends stack inspection for a sustained issue and a trace for intermittent behaviour. A stack snapshot shows where threads were at that moment; a trace can preserve events across a bounded period.1
For a persistent issue, Microsoft also describes capturing a process dump with dotnet-dump and inspecting it in Visual Studio, where Parallel Stacks groups similar call stacks. Choose that route only with approved capture impact, storage and sensitive-data handling; it is not required merely to obtain a quick stack sample.1
Read from the waiting primitive back to the application call site. Finding a common runtime frame is not enough. Determine which application operation invoked it, whether it occurs on the request path, and how many sampled workers share the pattern.
The .NET 9 tutorial documents the WaitHandleWait event, introduced in .NET 9, for blocking operations including task waits and certain locks. That event helps identify a waiting boundary; its presence alone does not prove that every wait is harmful or that a particular lock is the root cause.1
Map symbols to the deployed build. A source line from a different release can turn useful stack evidence into an incorrect fix. If symbols or the incident interval are missing, state that limitation in the finding.
Be precise about sync-over-async
Calling a genuinely asynchronous API and then synchronously waiting for its result occupies the calling thread. Microsoft's examples include Task.Result, Task.Wait and Task.GetAwaiter().GetResult() as relevant blocking operations.1
The corrective design usually needs an asynchronous path through its callers, not a cosmetic change at one line. Review cancellation, timeout handling, exception propagation and resource lifetime while changing that path. A returned task that nobody awaits is not an equivalent implementation.
Putting the blocking call inside additional queued work also does not establish that the underlying wait has disappeared. Require stack and latency evidence for the revised design rather than accepting a new task boundary as proof.
Some applications must integrate synchronous dependencies. In those cases, the design review should address bounded concurrency and isolation of that work. The correct capacity and isolation mechanism depend on the application; this article provides no universal thread-setting recipe.
Runtime improvements do not replace diagnosis
Microsoft notes that .NET 6 changed heuristics to increase threads more quickly for certain blocking task APIs. This can shorten starvation periods, but it does not make synchronous blocking a cost-free design.1
A service may stabilise at a high worker count after the pool compensates. That can reduce the immediate latency symptom while leaving the blocking pattern in place. Startup or a substantial workload change may expose another adjustment period.
Therefore, evaluate both steady-state and transition behaviour in an approved test environment. A healthy-looking sample after several minutes does not explain what users experienced during the initial surge.
Changing minimum or maximum worker settings without identifying the blocked work risks shifting symptoms and resource costs. Any temporary mitigation should have a stated objective, expiry and follow-up measurement.
Keep diagnostic collection bounded
Use an approved collection window and a destination with enough space. Stack traces, process arguments and diagnostic artefacts can reveal implementation details; restrict access and retention. The tool's ability to attach does not establish that collection is acceptable during a sensitive production interval.
This is an editorial collection precaution, not a claim that a specific capture will impose a measured amount of overhead. No overhead benchmark was performed here. Record the tool and configuration so any effect can be assessed in context.
For verification, compare representative workload, request percentiles, failures, worker growth and blocking stacks before and after the change. Include dependency latency so an improvement caused by a healthier dependency is not attributed solely to a code edit.
Learning notes
- Low CPU can coexist with unavailable ThreadPool workers.
- Worker growth, queueing and completion evidence must be read together.
- A blocking stack needs to be traced back to the application call site.
- Runtime and tool versions determine which counters and events are available.
Acceptance checklist
- [ ] Runtime/tool versions and time-correlated latency, workers, queueing and completion are recorded.
- [ ] Stacks or traces identify the application blocking boundary.
- [ ] The proposed correction addresses worker occupancy rather than merely raising concurrency.
- [ ] Representative approved load shows useful completion and acceptable latency without shifted resource pressure.
References
- Debug ThreadPool Starvation - .NET | Microsoft Learn. Accessed 2026-11-01.
- dotnet-counters diagnostic tool - .NET CLI - .NET | Microsoft Learn. Accessed 2026-11-01.