Starvation symptoms, Kestrel heartbeat warnings, dotnet-counters and dotnet-trace evidence, dump analysis, root causes and short- versus long-term fixes.
ThreadPool starvation happens when pool threads are blocked faster than the pool can add new ones, so queued work (requests, continuations, timers) waits. Symptoms: latency rises sharply while CPU stays low, the pool queue length grows, thread count climbs slowly, health checks time out and Kestrel logs heartbeat warnings mentioning possible starvation. I confirm it with dotnet-counters (queue length and thread count), then take dotnet-stack snapshots or a dump to see what the threads are blocked on, usually .Result, .Wait(), synchronous I/O or locks. The fix is to make that code asynchronous; raising SetMinThreads only buys time.
"ThreadPool starvation happens when pool threads get blocked faster than the pool can inject new ones. Queued work (incoming requests,
awaitcontinuations, timer callbacks) then just waits. The signature is unmistakable: latency explodes while CPU stays low, the pool queue length grows, thread count creeps up by about one or two per second, health probes time out, and Kestrel logs heartbeat warnings that mention possible thread pool starvation. I confirm it withdotnet-counters, then find the cause from stacks, viadotnet-stacksnapshots or a dump withparallelstacks, looking for many threads parked in the same frame:.Result/.Wait(), synchronous I/O, or a contended lock. To mitigate during the incident, I scale out or temporarily raise min threads. The fix is removing the blocking: async end to end, or isolating unavoidable blocking onto dedicated, bounded threads. Then I prevent it with analysers, load tests and a queue-length alert."
| Signal | Starvation | CPU saturation | Slow dependency |
|---|---|---|---|
| Latency | Very high | High | High |
| CPU | Low / moderate | ~100% | Low |
dotnet.thread_pool.queue.length |
Growing | May grow | Near 0 |
dotnet.thread_pool.thread.count |
Slowly rising | Stable | Stable |
| Stacks | Waiting (Task.Wait, GetResult, Monitor.Enter, sync I/O) |
Running user code | Few threads; many pending async ops |
| Kestrel | Heartbeat warnings | Possibly | No |
| Step | Command / tool | Look for |
|---|---|---|
| 1. Confirm | dotnet-counters monitor -n MyApi --counters System.Runtime |
Queue length up, threads creeping, CPU low |
| 2. Stacks (cheap) | dotnet-stack report -p <pid> × 2–3 |
Same blocking frame on many threads |
| 3. Dump (deep) | dotnet-dump collect → parallelstacks, threadpool, clrstack -all |
Hot frame and the call path into it |
| 4. Trace (optional) | dotnet-trace with ThreadPool events |
Thread injections with reason Starvation |
| 5. In containers | dotnet-monitor sidecar or kubectl debug |
Automated capture when queue length crosses a threshold |
| Cause | Where it hides |
|---|---|
Sync-over-async (.Result, .Wait(), GetAwaiter().GetResult()) |
Controllers, filters, DI factories, IOptions setup, third-party SDKs (auth token caches!) |
| Synchronous I/O | EF Core sync methods, HttpClient.Send, File.ReadAllText, sync stream reads |
| Long locks around slow work | Caches refreshed inside lock |
Long-running loops on Task.Run |
Background pollers occupying pool threads permanently |
| Synchronous logging sinks | Network sinks called on every request |
Thread.Sleep in request paths |
Retry loops |
| Action | Type | Notes |
|---|---|---|
| Scale out | Mitigation | Spreads load; doesn't remove blocking |
Raise min threads (System.Threading.ThreadPool.MinThreads) |
Mitigation | Creates threads immediately up to the minimum; costs memory/context switches; set an owner + removal date |
| Make the blocking path async | Fix | Including third-party upgrades |
| Move unavoidable blocking to dedicated threads with bounded concurrency | Fix | E.g. legacy SDK behind a Channel<T> + dedicated worker threads |
Shorten locks / use SemaphoreSlim |
Fix | No I/O under locks |
Our [API, ~3,000 requests/second] added a new authentication library the week before a [busy Monday]. That morning p99 hit [12 s] with CPU at [20%]; pool queue length was in the [thousands], Kestrel logged heartbeat warnings, and liveness probes started restarting pods, which made it worse. dotnet-stack snapshots from two pods showed [about 180] threads inside the library's token cache, blocked on .Result of an HTTP token request. I applied System.Threading.ThreadPool.MinThreads = 200 via runtime config as a stopgap, which stabilised latency within minutes, then switched to the library's async token API and removed the override [a week later]. We added a pre-release load test, enabled CA1849/VSTHRD002 as errors, and alerted on queue length.
A core senior production-debugging question.
Green flags
Red flags
request thread T1: client.GetStringAsync(url).Result -> blocks T1
the HTTP response arrives -> its continuation needs a pool thread to run
all pool threads are blocked in .Result waiting for continuations
-> continuations wait in the queue -> blocked threads keep waiting
-> only slow thread injection (gate thread / hill climbing) breaks the cycle
Each blocked thread waits on work that itself needs a free thread, so throughput collapses until injection catches up. Since .NET 6 the pool compensates faster when threads block on tasks, but it still lags behind a burst.
parallelstacks> parallelstacks
187 System.Threading.ManualResetEventSlim.Wait
187 System.Threading.Tasks.Task.SpinThenBlockingWait
187 System.Threading.Tasks.Task.InternalWaitCore
187 System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification
187 Vendor.Auth.TokenCache.GetToken() <-- culprit (sync wrapper over async)
187 Vendor.Auth.AuthHandler.SendAsync
...
The frame just above the wait frames is where the blocking was introduced.
Kestrel runs a heartbeat once per second on a timer. When pool threads are starved, the heartbeat itself runs late and Kestrel logs a warning that the heartbeat took longer than expected, noting it could be caused by thread pool starvation. Treat it as an early-warning signal and alert on it.
dotnet-monitor collection rules can watch runtime counters and collect stacks/dumps when, for example, queue length exceeds a threshold for 30 seconds:
{
"CollectionRules": {
"Starvation": {
"Trigger": { "Type": "EventCounter", "Settings": {
"ProviderName": "System.Runtime", "CounterName": "threadpool-queue-length",
"GreaterThan": 100, "SlidingWindowDuration": "00:00:30" } },
"Actions": [ { "Type": "CollectStacks" }, { "Type": "CollectDump", "Settings": { "Type": "WithHeap" } } ],
"Limits": { "ActionCount": 1, "ActionCountSlidingWindowDuration": "01:00:00" }
}
}
}
(Check counter names for your runtime version; .NET 9+ also exposes System.Diagnostics.Metrics-based names.)
// runtimeconfig.template.json
{ "configProperties": { "System.Threading.ThreadPool.MinThreads": 200 } }
Or the MSBuild property <ThreadPoolMinThreads>200</ThreadPoolMinThreads>, or ThreadPool.SetMinThreads(200, 200) at startup. Record it as a mitigation with an owner and a removal date.
// Legacy sync-only SDK: run it on a few dedicated threads, never on the pool
var requests = Channel.CreateBounded<(Request Req, TaskCompletionSource<Response> Tcs)>(500);
for (int i = 0; i < 4; i++)
new Thread(() =>
{
foreach (var (req, tcs) in requests.Reader.ReadAllAsync().ToBlockingEnumerable())
{
try { tcs.TrySetResult(legacy.Call(req)); }
catch (Exception ex) { tcs.TrySetException(ex); }
}
}) { IsBackground = true, Name = $"legacy-sdk-{i}" }.Start();
Callers await the TCS; only the four dedicated threads ever block.
| Version | Change |
|---|---|
| .NET Core 3.0 | dotnet-counters, dotnet-dump |
| .NET 5 | dotnet-stack |
| .NET 6 | Managed thread pool; faster compensation for blocking on tasks |
| .NET 7 | ToBlockingEnumerable() for IAsyncEnumerable<T> |
| .NET 9 | dotnet.thread_pool.* metrics in System.Runtime meter |
| .NET 10 | Same diagnosis approach |
/bad with bombardier -c 200 -d 60s; watch dotnet-counters./good with the same settings and compare.dotnet-stack report during the /bad test and find the blocking frame.System.Threading.ThreadPool.MinThreads = 300 and repeat /bad: better, but memory and threads are much higher.--cpus=1 and see starvation start at far lower concurrency.dotnet-monitor rule to capture stacks automatically when queue length is high.var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();
var app = builder.Build();
// BAD: sync-over-async blocks a pool thread for the whole call.
app.MapGet("/bad", (IHttpClientFactory f) =>
{
var client = f.CreateClient();
string body = client.GetStringAsync("https://example.com/").Result; // starvation under load
return body.Length;
});
// GOOD: the thread is released while the call is in flight.
app.MapGet("/good", async (IHttpClientFactory f, CancellationToken ct) =>
{
var client = f.CreateClient();
string body = await client.GetStringAsync("https://example.com/", ct);
return body.Length;
});
// Diagnostic endpoint (protect it in real systems).
app.MapGet("/diag/threadpool", () => new
{
Threads = ThreadPool.ThreadCount,
Pending = ThreadPool.PendingWorkItemCount,
Completed = ThreadPool.CompletedWorkItemCount
});
app.Run();
Commands:
dotnet-counters monitor -n MyApi System.Runtime
dotnet-stack report -p 1234 > stacks.txt
dotnet-dump collect -p 1234
SetMinThreads as the fixWhat happens: min threads raised to 500 and the incident closed. Why it is wrong: blocking remains; it fails again at higher load, with more memory and context switching. Do instead: use it to stabilise, then remove the blocking.
What happens: bigger instances are provisioned. Why it is wrong: threads are waiting, not computing. Do instead: check CPU and queue length together to distinguish starvation from saturation.
What happens: the team searches its repo for .Result and finds nothing.
Why it is wrong: SDKs and libraries often block internally.
Do instead: read the stacks: they include library frames.
What happens: liveness probes or engineers restart pods.
Why it is wrong: the evidence disappears; restarts can amplify the problem by moving load to other pods.
Do instead: capture stacks first; automate capture with dotnet-monitor.
What happens: probes time out and kill pods that would recover. Why it is wrong: cascading restarts reduce capacity further. Do instead: separate liveness from readiness; give liveness generous timeouts.
Task.Run in request handlersWhat happens: await Task.Run(() => SyncCall()) as a "fix".
Why it is wrong: the blocking just moves to another pool thread.
Do instead: true async APIs, or dedicated threads for unavoidable blocking.
What happens: warnings scroll by in logs. Why it is wrong: they're an early signal of starvation. Do instead: alert on them.
What happens: load tests with concurrency below the pool minimum. Why it is wrong: starvation only appears beyond the minimum. Do instead: test bursts well above core count, in the real container shape.
What happens: .Result in a singleton factory or options setup.
Why it is wrong: first requests under load block many threads at once.
Do instead: async initialisation in hosted services or lazy async patterns.
What happens: dashboards show only CPU and latency. Why it is wrong: you can't tell starvation from other slowness. Do instead: dashboard and alert on pool queue length and thread count.
Starvation is the most common async production failure I see, and it is always diagnosable from stacks. I keep queue length on dashboards, alert on it, and make sure there is a runbook for capturing stacks inside containers.
| Phase | Action |
|---|---|
| Detect | Alert on pool queue length and Kestrel heartbeat warnings |
| Stabilise | Scale out, temporary min threads |
| Diagnose | dotnet-stack, dump, parallelstacks |
| Fix | Async end to end, isolate unavoidable blocking |
| Prevent | Analysers, load tests, review checklist |
The ThreadPool is a process-wide set of reusable background threads that run queued work items: Task.Run, async continuations, timer callbacks and ASP.NET Core requests. Work goes into a global queue or, when queued from a pool thread, into that thread's local queue, and idle threads steal from others. The pool starts up to minimum threads (default: processor count) immediately on demand; above that it adds threads gradually, using a hill-climbing algorithm that measures throughput plus a starvation detector that injects threads when queued work is not progressing. That gradual injection is why blocking pool threads causes latency spikes.
Sync-over-async is synchronously blocking on asynchronous work: .Result, .Wait(), GetAwaiter().GetResult() or Task.WaitAll. It holds a thread for the whole operation, causes ThreadPool starvation on servers and deadlocks under a SynchronizationContext. It sneaks in through constructors, DI factory lambdas, property getters, Dispose, lock blocks, synchronous interfaces (IComparer, old filters), static initialisers and "quick fixes" in legacy code. Remove it by making the call chain async, using async factory methods or lazy async initialisation, IAsyncDisposable, hosted-service warm-up, and async-capable interfaces. Where a sync boundary is unavoidable (for example a legacy interface), keep it at the edge and document it.
Any call that holds a request thread while waiting is a blocking call: sync-over-async (.Result, .Wait()), synchronous I/O (Stream.Read on the request body, File.ReadAllText, EF Core ToList()/SaveChanges(), HttpClient.Send), Thread.Sleep, long lock sections and synchronous logging to slow sinks. Kestrel disallows synchronous request and response body I/O by default (AllowSynchronousIO = false) and throws InvalidOperationException if you try. Replace them with async equivalents (ReadAsync, ToListAsync, SaveChangesAsync, SendAsync, Task.Delay, SemaphoreSlim.WaitAsync), find them with analysers (CA1849), stack snapshots and load tests.
How to tell it (STAR)
On a [Monday morning] our API slowed to twelve-second responses with only 20% CPU. Queue length was huge and Kestrel warned about starvation. Stack snapshots showed most threads blocked in a new auth library's synchronous token fetch. I raised min threads to stabilise, then switched to the library's async API and removed the workaround. We now load test every release and alert on queue length.
Last updated 31 Oct 2026.