Your API is running. Your server is healthy. But requests are taking forever. Could ThreadPool starvation be the reason?
Introduction
Imagine you've developed 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:

Join the conversation! Your thoughts help the community grow.