Diagnosing ASP.NET Core Thread‑Pool Starvation from Sync‑Over‑Async Blocking
When ASP.NET Core requests suddenly spike in latency while CPU stays low, the culprit is often thread‑pool starvation caused by sync‑over‑async blocking. This guide shows how to spot the symptoms, run targeted diagnostics, map findings to fixes, and know when to ask the runtime team.
30 Jan 2026, 13:07 UTC

Recognizable Condition
When a web application’s request latency climbs in discrete steps—p99 jumps from 200 ms to 800 ms to 2 s—while CPU usage stays around 10–30 %, you’re likely seeing thread‑pool starvation. The symptoms are:
- Steady CPU, low memory pressure.
- ThreadPool queue length grows to hundreds or thousands.
- Thread count climbs slowly toward the configured maximum.
- Requests time out or return 503 under load.
- All endpoints affected, not just a single route.
These patterns often surface after adding code that blocks an async I/O operation, such as .Result, .Wait, or Task.Run().Result inside a request pipeline.
Cause & Diagnostic Table
| Signal | Likely Cause |
|---|---|
Stack contains Wait or Result | Sync‑over‑async blocking |
| ThreadPool queue length high, completed‑items rate low | ThreadPool min threads too low or long ramp‑up |
| Connection resets or 503s after heavy load | Kestrel backlog or connection limit reached |
| Common stack frames across all requests | Blocking in middleware/filters |
Ordered Checks
- Capture EventCounters
Run the counters against the running process. Replace
12345with your process ID.dotnet-counters monitor --process-id 12345 \ --counters System.Threading.ThreadPool,System.Runtime \ --period 1Look for
ThreadPoolQueueLengthandThreadPoolThreadCount. A steady rise without a corresponding CPU spike confirms starvation. - Collect a Full Dump at Peak Latency
Use
dotnet-dumpto grab a snapshot. This requires the user to have read access to the process.dotnet-dump collect --process-id 12345 --dump-file dump.dmpStore the file in a safe location; it can be large (hundreds of MB).
- Analyze Stack Traces
Open the dump with
dotnet-dump analyzeor a tool like WinDbg. Search for frames containingWait,Result,Thread.Sleep, or synchronous I/O APIs (e.g.,FileStream.Read,SqlConnection.Open).Example snippet (pseudo‑output):
0x00007ff... MyApp.Controllers.HomeController.Index (Async) 0x00007ff... System.Threading.Tasks.Task.get_Result() 0x00007ff... System.Net.Http.HttpClient.GetAsync() - Review Recent Code Changes
Check pull requests or commits that added
.Result,.Wait, or wrapped async work inTask.Run().Result. Also look forThread.Sleepinside request‑handling code. - Validate ThreadPool and Kestrel Limits
By default, .NET Core sets the thread‑pool min threads to 0, allowing a slow ramp‑up. If you see a queue that grows but the thread count stays near the minimum, consider
ThreadPool.SetMinThreadsas a temporary mitigation. Kestrel’s default backlog is 100; increaseListenOptions.Backlogonly after confirming that the backlog is the real bottleneck.public void Configure(KestrelServerOptions options) { options.Listen(IPAddress.Any, 5000, listenOptions => { listenOptions.Backlog = 200; // temporary }); }
Fixes Tied to Findings
- Convert sync‑over‑async to true async
Replace
.Resultor.Waitwithawaitthroughout the request pipeline. Ensure all called methods are fully async.// Bad var data = dbContext.Users.FirstOrDefaultAsync(u => u.Id == id).Result; // Good var data = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == id); - Remove blocking calls in middleware
Middleware that calls
Thread.Sleepor synchronous I/O should be rewritten to async equivalents or moved out of the request path. - Reserve
Task.Runfor CPU‑bound work onlyDo not use
Task.Run().Resultto offload I/O; instead, use the async API directly. - Temporary thread‑pool mitigation
If you must keep a sync call for a short period, raise the min threads:
ThreadPool.SetMinThreads(200, 200); // 200 worker + 200 I/O threadsRemember: this hides the real issue and can increase context switching.
- ConfigureAwait(false) in libraries
Use
ConfigureAwait(false)in library code that does not depend on the ASP.NET synchronization context. Avoid it in request handlers that needHttpContextor services resolved from the request scope. - Use
ValueTaskonly after profilingHot paths that return a value synchronously can use
ValueTaskto reduce allocations, but only if you’ve measured the benefit.
Escalation Criteria
- If, after removing all blocking calls, the queue length still climbs and latency remains high, suspect a runtime bug or a GC pause. Capture a GC histogram with
dotnet-trace collect --providers Microsoft-Windows-DotNETRuntime:0x0000000000000400and analyze for long GC pauses. - If the problem persists on a clean install of the same .NET version, file a bug with Microsoft. Include:
- Runtime version and OS details.
- EventCounter export (CSV).
- Full dump and stack analysis.
- A minimal reproducible repro that triggers the stall.
- Do not raise
ThreadPool.SetMinThreadspermanently; treat it as a mitigation until the async refactor is complete.
Practical Validation Checklist
- Run a staged load test with the same traffic profile that produced the stall.
- Verify that
dotnet-countersshowsThreadPoolQueueLengthstaying below 50 andThreadPoolThreadCountnear the default minimum. - Assert that p99 latency remains under the SLA threshold (e.g.,
<200 ms). - Confirm that the dump no longer contains
WaitorResultframes.
Limitations and Caveats
- EventCounter names and default values vary between .NET 6, 7, 8, and hosting models (IIS, Docker, Kestrel). Verify against the exact runtime you’re troubleshooting.
- Collecting dumps and traces adds overhead; perform them during controlled testing, not during an unmitigated production incident.
- Raising thread‑pool min threads can mask the real issue and increase memory usage; use only as a temporary stopgap.
- ConfigureAwait(false) can break code that relies on the request synchronization context; apply it only in library code that does not touch
HttpContextor scoped services.
Conclusion
Thread‑pool starvation from sync‑over‑async blocking is a common cause of sudden ASP.NET Core latency spikes. By following the ordered checks above—capturing counters, dumping the process, analyzing stacks, reviewing recent changes, and validating thread‑pool settings—you can pinpoint the offending code. Replace blocking calls with true async, only use ThreadPool.SetMinThreads as a temporary mitigation, and escalated to runtime owners only when the root cause remains elusive.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.