Ever come across a production issue where requests start timing out, and the app looks dead, yet you have low CPU usage such as 10% or 20%, memory is fine, and no exception appears in the logs. Then, minutes later, the app may recover on its own.
This is ThreadPool starvation. The code isn't crashing and the server isn't out of resources, it's just the app has run out of threads that are free to do work.
How ThreadPool are handled
The pool starts with a minimum number of threads. When demand stays above what the existing threads can handle, the pool doesn't create new threads instantly.
Under starvation it injects new ones slowly.
That works fine as long as threads are busy briefly and then return to the pool. It breaks when threads are occupied but not working.
What Causes Starvation
Starvation happens when work arrives faster than threads become free, and the threads are tied up doing nothing useful. The usual problem is blocking, where a thread waits for something instead of being released.
1. Sync-over-async
app.MapGet("/users/{id}", (int id, UserService svc) =>
{
var user = svc.GetUsersAsync(id).Result; // blocks a pool thread
return Results.Ok(user);
});
.Result, .Wait(), and .GetAwaiter().GetResult() hold the thread while the I/O happens elsewhere.
ASP.NET Core has no SynchronizationContext, which dictates where the continuation of code (like the part following an await statement) resumes executing.
The worst part is that the blocked code often needs another pool thread to run its continuation, so a pool full of blocked threads can't even finish the work they're waiting on.
2. Synchronous I/O and sleeps
Thread.Sleep(2000); // thread does nothing for 2 seconds
var text = File.ReadAllText(path); // blocks on disk
var rows = dbContext.Orders.ToList(); // blocks on the database
var body = httpClient.Send(request); // blocks on the networkEvery one of these has an async counterpart (Task.Delay, File.ReadAllTextAsync, ToListAsync, SendAsync).
3. Other contributors
Parallel.ForEachit holds many pool threads while they wait on the network.- locks. Threads queue up on a
lockwhile the holder does slow work. - Blocking in constructors, middleware, or startup paths that run per request.
Prevention
Go async all the way
This is the main fix. Every layer between the endpoint and the I/O should return a Task and use await:
app.MapGet("/users/{id}", async (int id, UserService svc, CancellationToken ct) =>
{
var user= await svc.GetUserAsync(id, ct);
return Results.Ok(user);
});While the await is pending, the thread returns to the pool and can serve other requests.
Never block on a Task
Treat .Result, .Wait(), and .GetAwaiter().GetResult() as bugs in request paths. The analyzer VSTHRD002 (from Microsoft.VisualStudio.Threading.Analyzers) flags them at build time.
Add timeouts and cancellation
A slow dependency should fail fast instead of holding resources:
builder.Services.AddHttpClient<PaymentsClient>(c =>
{
c.Timeout = TimeSpan.FromSeconds(5);
});Quick Checklist
Question | Action |
|---|---|
Is CPU low while latency is high? | Suspect starvation, check |
Any | Replace with |
Do outbound calls have timeouts? | Set them, add cancellation tokens |

Join the conversation! Your thoughts help the community grow.