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 network

Every one of these has an async counterpart (Task.Delay, File.ReadAllTextAsync, ToListAsync, SendAsync).

3. Other contributors

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 ThreadPool Queue Length

Any .Result, .Wait(), GetResult() in request paths?

Replace with await, add VSTHRD002

Do outbound calls have timeouts?

Set them, add cancellation tokens