Why Your ASP.NET Core API Becomes Slow and How to Resolve It: ThreadPool Starvation in .NET?
Your API is operational. Your server is in good health. However, requests are taking a very long time. Is hunger in ThreadPool the cause?
Overview
Let’s say you have created an ASP.NET Core Web API.
Everything works perfectly during development.
- API responses are fast.
- Database queries are optimized.
- CPU usage looks normal.
- No obvious exceptions appear in the logs.
But suddenly, when hundreds of users start accessing your application, something unexpected happens.
Your API becomes extremely slow.
Requests begin waiting, response times increase, and some requests eventually time out.
You check your SQL Server database, network connectivity, and application logs, but nothing obvious explains the problem.
One possible reason is ThreadPool starvation.
ThreadPool starvation is a performance problem that can affect the responsiveness and scalability of .NET applications.
In this blog, we’ll understand what ThreadPool starvation is, why it happens, how to identify it, and how to fix it using practical C# examples.
1. What Is a ThreadPool in .NET?
Before understanding starvation, we need to understand the ThreadPool.
A ThreadPool is a collection of reusable worker threads managed by the .NET runtime.
Instead of creating a new operating system thread for every unit of work, .NET can reuse existing worker threads.
The ThreadPool helps execute tasks such as:
- CPU-bound work scheduled using
Task.Run(). - Task continuations that require ThreadPool execution.
- Background work items.
- Parts of ASP.NET Core request processing.
Simple Example
await Task.Run(() =>
{
Console.WriteLine("Executing on ThreadPool");
});
Here, Task.Run() schedules work on the ThreadPool.
The runtime manages thread creation, scheduling, and reuse.
Important: Not every asynchronous operation requires a worker thread while it is waiting. For example, a properly asynchronous database or HTTP request can wait for I/O without occupying a worker thread throughout the wait.
This distinction is extremely important when building scalable APIs.
2. What Is ThreadPool Starvation?
ThreadPool starvation occurs when available ThreadPool worker threads cannot keep up with the work that needs to execute.
As a result, queued work experiences delays.
Imagine a restaurant with five waiters.
All five waiters are occupied waiting for orders to be prepared, instead of serving other customers.
New customers arrive, but no waiter is available to assist them.
The restaurant becomes slow even though the kitchen may not be overloaded.
A similar situation can occur in .NET applications.
If worker threads are blocked for long periods, other work must wait until threads become available or the runtime adds more workers.
Symptoms
- API response times increase significantly.
- Requests may time out.
- ThreadPool thread count may grow.
- CPU usage can remain relatively low.
- Queued work may increase.
- Application throughput may decrease.
Low CPU usage combined with increasing worker thread counts and high latency can be an important warning sign, although it doesn’t prove starvation by itself.
3. What Causes ThreadPool Starvation?
Cause 1: Blocking Asynchronous Code with .Result
Consider this example:
[HttpGet("employees")]
public IActionResult GetEmployees()
{
var employees = GetEmployeesAsync().Result;
return Ok(employees);
}
private async Task<List<string>> GetEmployeesAsync()
{
await Task.Delay(2000);
return new List<string>
{
"Rahul",
"Priya",
"Amit"
};
}
What’s wrong here?
GetEmployeesAsync() returns a Task, but .Result synchronously blocks the request thread until the operation completes.
When many concurrent requests execute this code, multiple worker threads can become blocked.
The runtime may need to inject additional threads to process pending work, causing delays and increased resource usage.
Solution: Use async and await
[HttpGet("employees")]
public async Task<IActionResult> GetEmployees()
{
var employees = await GetEmployeesAsync();
return Ok(employees);
}
Now the request can asynchronously wait without blocking a worker thread throughout the delay.
Best practice: Avoid .Result, .Wait(), and GetAwaiter().GetResult() in asynchronous ASP.NET Core request paths.
Cause 2: Using Thread.Sleep() in API Requests
Consider:
[HttpGet("process")]
public IActionResult Process()
{
Thread.Sleep(5000);
return Ok("Completed");
}
Here, Thread.Sleep(5000) blocks the executing thread for five seconds.
If hundreds of requests arrive concurrently, many threads can become occupied doing nothing useful.
Solution: Use Task.Delay() for Asynchronous Waiting
[HttpGet("process")]
public async Task<IActionResult> Process(
CancellationToken cancellationToken)
{
await Task.Delay(5000, cancellationToken);
return Ok("Completed");
}
Task.Delay() schedules an asynchronous delay rather than blocking the worker thread for the entire waiting period.
Use this for genuinely asynchronous waiting, such as a retry delay—not as a replacement for CPU-intensive work.
Cause 3: Blocking External API Calls
Imagine your application communicates with a payment service.
public string GetPaymentStatus()
{
using var client = new HttpClient();
var response = client
.GetAsync("https://api.example.com/payment/status")
.Result;
return response.Content
.ReadAsStringAsync()
.Result;
}
This implementation synchronously waits for asynchronous HTTP operations.
If the external API takes several seconds to respond, the current worker thread remains blocked during that wait.
Solution: Use Asynchronous HTTP Calls
public class PaymentService
{
private readonly HttpClient _httpClient;
public PaymentService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> GetPaymentStatusAsync(
CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(
"https://api.example.com/payment/status",
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content
.ReadAsStringAsync(cancellationToken);
}
}
Register the service using AddHttpClient<PaymentService>().
This approach supports asynchronous I/O, cancellation, and centralized HTTP client configuration.
For production applications, also configure appropriate timeouts and resilience policies.
Cause 4: Synchronous Database Operations
Database queries are another common source of blocking.
public List<Employee> GetEmployees()
{
return _context.Employees.ToList();
}
ToList() executes the database query synchronously.
When the database is slow, the request thread remains occupied waiting for the result.
Solution: Use EF Core Asynchronous Methods
public async Task<List<Employee>> GetEmployeesAsync(
CancellationToken cancellationToken)
{
return await _context.Employees
.AsNoTracking()
.ToListAsync(cancellationToken);
}
ToListAsync() enables asynchronous database access when supported by the provider.
This can improve request concurrency under I/O-heavy workloads.
Remember: asynchronous database calls don’t automatically make the SQL query execute faster. They help avoid blocking application threads while waiting.
4. Real-World Scenario: 1,000 Concurrent API Requests
Consider an API that calls an external service.
Suppose:
- 1,000 requests arrive around the same time.
- Each request waits two seconds for an external API.
- The application uses
.Resultto wait synchronously.
Potentially, many ThreadPool threads become blocked waiting for network responses.
Other requests and continuations may then be delayed.
Now consider the asynchronous approach:
[HttpGet("orders/{id:int}")]
public async Task<IActionResult> GetOrder(
int id,
CancellationToken cancellationToken)
{
var order = await _orderService
.GetOrderAsync(id, cancellationToken);
return order is null ? NotFound() : Ok(order);
}
If GetOrderAsync() uses genuinely asynchronous I/O throughout, its waiting periods don’t require dedicated worker threads.
This can allow the application to support more concurrent I/O-bound requests with fewer blocked threads.
Important: Asynchronous programming doesn’t guarantee that 1,000 requests will complete successfully. Database capacity, connection pools, external API limits, memory, and infrastructure still matter.
5. Does Task.Run() Solve ThreadPool Starvation?
A common misconception is that wrapping blocking code in Task.Run() makes it scalable.
For example:
await Task.Run(() =>
{
Thread.Sleep(5000);
});
This doesn’t solve the underlying problem.
It simply moves the blocking operation to another ThreadPool worker thread.
That worker still remains blocked for five seconds.
When Should You Use Task.Run()?
Task.Run() is generally useful for scheduling CPU-bound work when there is a good reason to move it off the current execution context.
However, in ASP.NET Core, wrapping synchronous I/O or request work in Task.Run() usually adds overhead rather than improving scalability.
For long-running or expensive background processing, consider a managed background service, queue, or dedicated worker process.
6. How to Detect ThreadPool Starvation in Production
One of the most useful tools for investigating .NET performance problems is dotnet-counters.
Install it:
dotnet tool install --global dotnet-counters
Identify your application process:
dotnet-counters ps
Then monitor runtime counters:
dotnet-counters monitor --process-id <PID> System.Runtime
Depending on the .NET runtime version, counter names and display formats can vary.
Look for these indicators:
| Metric | What to look for |
|---|---|
| ThreadPool thread count | Rapid or sustained growth |
| ThreadPool queue length | Work accumulating in the queue |
| CPU usage | Low or moderate despite poor throughput |
| Request latency | Increasing response times |
| Completed work items | Throughput not keeping up with incoming work |
No single metric proves ThreadPool starvation.
Combine runtime counters with request metrics, logs, traces, and thread-stack analysis.
Tools such as dotnet-stack and dotnet-trace can help identify blocked threads and problematic call paths.
7. How to Fix ThreadPool Starvation
Here are practical solutions for ASP.NET Core applications.
1. Use asynchronous I/O
Prefer await, ToListAsync(), GetAsync(), and other asynchronous APIs when performing I/O.
2. Avoid sync-over-async
Remove unnecessary .Result, .Wait(), and other blocking waits from asynchronous execution paths.
3. Avoid Thread.Sleep() in request processing
Use Task.Delay() for asynchronous delays and retries.
4. Limit concurrency where appropriate
Use mechanisms such as SemaphoreSlim to limit access to expensive resources or downstream services.
private readonly SemaphoreSlim _semaphore = new(10);
public async Task ProcessAsync(
CancellationToken cancellationToken)
{
await _semaphore.WaitAsync(cancellationToken);
try
{
await PerformIoOperationAsync(cancellationToken);
}
finally
{
_semaphore.Release();
}
}
This example limits the operation to 10 concurrent executions per semaphore instance. In a real application, the semaphore should be shared by the callers whose concurrency you want to limit.
5. Move lengthy background jobs outside API requests
Use BackgroundService, hosted workers, or a message queue for work that doesn’t need to finish during the HTTP request.
6. Investigate slow dependencies
A slow database or external service can amplify blocking problems.
Optimize queries, set appropriate timeouts, and avoid uncontrolled retries.
7. Monitor ThreadPool behavior
Track thread counts, queue lengths, CPU usage, and request latency to detect problems before they affect users.
8. Should We Increase the Minimum ThreadPool Threads?
You may encounter code like:
ThreadPool.SetMinThreads(100, 100);
Does this solve starvation?
Not necessarily.
Increasing the minimum number of worker threads may reduce thread-injection delays in some workloads, but it doesn’t remove blocking operations.
It can also increase resource consumption and context switching.
Modern .NET runtimes include ThreadPool heuristics that react to certain blocking patterns, but application-level blocking can still cause performance problems.
Treat ThreadPool tuning as a measured optimization—not the first solution.
Fix the root cause before changing runtime thread settings.
9. ThreadPool Starvation vs Deadlock
These concepts are related to concurrency, but they are not the same.
| ThreadPool Starvation | Deadlock |
|---|---|
| Work waits because worker threads are unavailable or insufficient | Operations wait on dependencies that prevent one another from progressing |
| Can recover when workers become available | Often cannot recover without external intervention |
| Commonly caused by blocking and thread saturation | Commonly caused by circular lock or resource dependencies |
| Causes high latency and reduced throughput | Can cause operations to stop progressing entirely |
A blocked thread doesn’t automatically mean a deadlock.
Similarly, ThreadPool starvation can occur without any deadlock.
10. Senior .NET Developer Interview Questions
Q1. Can ThreadPool starvation happen when CPU usage is low?
Yes. Threads can be blocked waiting for I/O, locks, or synchronous operations while CPU usage remains low.
Q2. Does using async and await create a new thread?
No. async and await don’t automatically create threads. For genuinely asynchronous I/O, the operation can wait without occupying a worker thread.
Q3. Can ThreadPool starvation happen even if we use async methods?
Yes. An application may still contain synchronous blocking, lock contention, or excessive CPU-bound work, even if its methods use the async keyword.
Q4. Why is .Result dangerous in ASP.NET Core?
It blocks the calling thread while waiting for a task to complete. Under heavy concurrency, repeated blocking can contribute to ThreadPool starvation.
Q5. How would you investigate ThreadPool starvation in production?
I would first check request latency, CPU utilization, ThreadPool thread counts, and queued work.
Then I would capture runtime traces or thread stacks to identify blocking operations such as .Result, .Wait(), synchronous database calls, or lock contention.
After identifying the root cause, I would fix the blocking code, run load tests, and compare performance metrics.
Conclusion
ThreadPool starvation is an important performance issue that every ASP.NET Core developer should understand.
It can make an application slow even when the database appears healthy and CPU utilization isn’t particularly high.
The most important lesson is that blocking threads while waiting for I/O reduces the application’s ability to handle concurrent requests efficiently.
By using asynchronous I/O correctly, avoiding sync-over-async, controlling concurrency, and monitoring runtime behavior, we can build more responsive and scalable .NET applications.
Key Takeaway
Async doesn’t make an individual operation magically faster. It helps your application avoid wasting threads while waiting.
Writing scalable applications isn’t only about increasing server resources. It’s about using those resources efficiently.
ASP.NET Core 11.0 Hosting Recommendation
HostForLIFE.eu
HostForLIFE.eu is a popular recommendation that offers various hosting choices. Starting from shared hosting to dedicated servers, you will find options fit for beginners and popular websites. It offers various hosting choices if you want to scale up. Also, you get flexible billing plans where you can choose to purchase a subscription even for one or six months.
