An ASP.NET Core API can have healthy CPU and memory utilization and still experience increasing latency, request timeouts, and degraded throughput.
One possible explanation is ThreadPool starvation.
ThreadPool starvation occurs when ThreadPool worker threads are occupied for extended periods, often because application code is performing blocking waits or synchronous I/O. As available workers become constrained, incoming work can spend more time waiting before execution begins.
For high-concurrency APIs, understanding this behavior requires looking beyond infrastructure metrics and examining what the .NET runtime is doing.
Understanding the ThreadPool
ASP.NET Core uses the .NET ThreadPool to execute application work, including request processing and other asynchronous operations.
Consider this code:
public IActionResult GetData()
{
var result = service.GetDataAsync().Result;
return Ok(result);
}Although GetDataAsync() returns a Task, calling .Result blocks the current worker thread until the operation completes.
The same problem can occur with:
service.GetDataAsync().Wait();Under low concurrency, these calls may appear harmless. Under sustained load, however, many requests can occupy ThreadPool workers while waiting for I/O.
The result can be a feedback loop:
Incoming requests → blocked workers → queued work → increasing latency → more concurrent requests → additional blocked workers
This is one reason ThreadPool starvation can become difficult to identify from CPU utilization alone.
Why CPU Utilization Can Be Misleading
A common troubleshooting assumption is:
High latency + low CPU = infrastructure problem.
That isn't necessarily true.
If worker threads are waiting on synchronous operations, the CPU may remain relatively underutilized while application work continues to accumulate.
Useful signals to examine include:
ThreadPool thread count
ThreadPool queue length
Work-item throughput
Request latency
Request queue duration
Exception and timeout rates
Garbage collection activity
External dependency latency
The important point is to correlate runtime metrics with application-level metrics rather than examining CPU or memory in isolation.
A Common Sync-over-Async Pattern
Consider an API endpoint that performs an asynchronous database operation:
public async Task<IActionResult> GetCustomer(int id)
{
var customer = await repository.GetCustomerAsync(id);
return Ok(customer);
}The request thread isn't synchronously waiting for the I/O operation.
Compare that with:
public IActionResult GetCustomer(int id)
{
var customer = repository.GetCustomerAsync(id).Result;
return Ok(customer);
}The second implementation introduces synchronous blocking.
The solution isn't simply to change the controller signature to async.
The asynchronous behavior needs to continue through the call chain:
Controller
↓
Service
↓
Repository
↓
Database/HTTP Client
↓
Async I/OIf a dependency in the middle of this chain performs synchronous blocking, the application can still experience scalability problems.
Hidden Blocking in Dependencies
Application code isn't always the source of the problem.
Third-party SDKs, database providers, HTTP clients, file APIs, and legacy libraries can introduce blocking behavior.
For example:
var response = externalClient.GetDataAsync().Result;Even if the external operation itself is I/O-bound, .Result keeps the current worker occupied while waiting.
When investigating production starvation, review the complete dependency chain rather than searching only for .Result and .Wait() in controller code.
Diagnosing the Runtime
1. Monitor with dotnet-counters
dotnet-counters can provide real-time runtime counters that help establish whether ThreadPool activity is changing as latency increases.
A typical investigation might involve:
dotnet-counters monitor --process-id <PID>Correlate ThreadPool-related counters with:
Request rate
Response latency
Queue length
Error rate
Throughput
The goal isn't to look at one counter in isolation. You're looking for a pattern between workload, ThreadPool behavior, and application performance.
2. Capture Runtime Traces
When counters indicate abnormal behavior but don't explain why it is happening, runtime tracing can provide deeper visibility.
dotnet-trace collect --process-id <PID>A trace can help engineers investigate thread activity, blocking behavior, runtime events, and periods of increased waiting.
This is particularly useful when the problem appears intermittently under production-like concurrency.
3. Profile Before Production
Performance profiling in development or staging environments can help identify blocking operations before they become production incidents.
Load testing is especially valuable when combined with profiling because a blocking operation that looks insignificant with a few concurrent requests can behave very differently at hundreds or thousands of concurrent requests.
ThreadPool Configuration Is Not the First Fix
Increasing the minimum number of ThreadPool threads may appear to improve a workload temporarily.
However, configuration changes should not be used as a substitute for removing unnecessary blocking.
If application code consistently occupies worker threads with synchronous waits, increasing available workers can simply delay the point at which the bottleneck becomes visible.
The first question should therefore be:
Why are ThreadPool workers blocked?
Only after understanding the workload should ThreadPool configuration be considered.
Designing the Application to Avoid Starvation
A scalable ASP.ET Core API should minimize unnecessary blocking throughout its execution path.
Key practices include:
Use asynchronous APIs for I/O
Prefer:
var data = await repository.GetDataAsync();over:
var data = repository.GetDataAsync().Result;Keep async operations asynchronous
Avoid introducing synchronous waits between asynchronous layers.
Review external integrations
HTTP calls, database operations, cloud services, and SDKs should be evaluated for proper asynchronous support.
Move long-running work away from request paths
If an operation doesn't need to execute during the HTTP request, consider background processing or messaging rather than keeping the request thread occupied.
Measure under realistic concurrency
A performance test should reproduce realistic concurrency, dependency latency, payload sizes, and traffic patterns rather than testing only average request volume.
ThreadPool Starvation vs. Infrastructure Scaling
Adding more application instances can increase overall capacity, but it doesn't necessarily eliminate the underlying blocking behavior.
For example, if every instance contains the same synchronous bottleneck, horizontal scaling may simply distribute the same inefficient execution pattern across more servers.
This is why application-level optimization should come before assuming that more compute capacity is the solution.
A Practical Investigation Workflow
When an ASP.NET Core API starts timing out under load, a useful investigation sequence is:
Confirm the symptom — Check latency, throughput, timeouts, and request queues.
Inspect runtime metrics — Look at ThreadPool activity and queued work.
Search for blocking operations — Review
.Result,.Wait(), synchronous I/O, and blocking integrations.Analyze dependencies — Check database providers, HTTP clients, SDKs, and third-party libraries.
Capture runtime traces — Use tracing when counters aren't sufficient.
Reproduce under load — Validate the suspected bottleneck with realistic concurrency.
Fix the root cause — Remove unnecessary blocking and maintain asynchronous execution through the dependency chain.
Monitor after deployment — Confirm that latency, throughput, and runtime behavior improve in production.
Final Thoughts
ThreadPool starvation is a runtime-level scalability problem that can remain hidden behind apparently healthy infrastructure metrics.
For ASP.NET Core applications handling concurrent requests, the important question isn't simply whether the server has enough CPU or memory. It's whether the application can efficiently use its available worker threads while waiting for I/O and processing incoming work.
Understanding sync-over-async behavior, dependency execution, ThreadPool metrics, runtime traces, and realistic load patterns gives developers a much stronger foundation for diagnosing these incidents.

Join the conversation! Your thoughts help the community grow.