Synchronous I/O and AllowSynchronousIO, EF Core sync APIs, HttpClient.Send, Thread.Sleep, long locks, heavy synchronous logging; detection with analysers, traces and load tests.
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.
"In ASP.NET Core a blocking call is anything that holds a request thread while it waits. The usual suspects are sync-over-async (
.Result,.Wait()), synchronous I/O (reading the request body withReadToEnd,Request.Form,File.ReadAllText, EF Core'sToList()/SaveChanges(),HttpClient.Send),Thread.Sleepin retry loops, locks around I/O, and synchronous logging to slow sinks. Each one removes a pool thread for its whole duration. Under load that becomes ThreadPool starvation: higher latency and lower throughput for every endpoint, not just the slow one. Kestrel helps: since 3.0 it disallows synchronous body I/O by default (AllowSynchronousIO = false) and throws instead, and turning that back on just hides the problem. Replacements are the async equivalents:ReadAsync,ReadFormAsync,ToListAsync,SaveChangesAsync,SendAsync,await Task.Delay,SemaphoreSlim.WaitAsync, async/batched logging. To find blocking I use analysers (CA1849, VSTHRD), stack snapshots under load, and load tests that watch pool queue length."
200 concurrent uploads from slow mobile clients
sync ReadToEnd holds a thread ~5 s each -> 200 blocked pool threads
pool minimum = cores (e.g. 4) -> slow injection -> queue grows
every other endpoint waits behind it -> p99 for /health, /login, /search spikes
| Blocking | Async replacement |
|---|---|
task.Result, task.Wait(), GetAwaiter().GetResult() |
await task |
Request.Body.Read, StreamReader.ReadToEnd() |
ReadAsync, ReadToEndAsync, ReadFromJsonAsync, model binding |
Request.Form (sync property) |
await Request.ReadFormAsync(ct) / [FromForm] binding |
IFormFile.CopyTo |
CopyToAsync |
Response.Body.Write, Response.WriteAsync(...).Wait() |
await Response.WriteAsync(...) |
EF Core ToList, First, Count, SaveChanges |
ToListAsync, FirstAsync, CountAsync, SaveChangesAsync |
HttpClient.Send |
SendAsync, GetFromJsonAsync |
File.ReadAllText, File.WriteAllText |
ReadAllTextAsync (or cache at startup) |
Thread.Sleep |
await Task.Delay / resilience pipeline |
lock around I/O |
SemaphoreSlim.WaitAsync, or remove shared state |
Process.WaitForExit() |
WaitForExitAsync |
next(context).Wait() in middleware |
await next(context) |
Sync filters doing I/O (IActionFilter) |
IAsyncActionFilter |
| Sync logging to network sinks | Async/batched sinks, background-queued providers |
| Setting | Default | Behaviour |
|---|---|---|
KestrelServerOptions.AllowSynchronousIO |
false (since ASP.NET Core 3.0) |
Sync body reads/writes throw InvalidOperationException: Synchronous operations are disallowed |
IIS in-process IISServerOptions.AllowSynchronousIO |
false |
Same |
If a legacy library requires sync I/O, allow it per request via IHttpBodyControlFeature.AllowSynchronousIO for that endpoint only, and track it as a defect, rather than enabling it globally.
| Technique | Catches |
|---|---|
| CA1849, VSTHRD002, VSTHRD103 | Sync calls where async exists; sync waits |
dotnet-stack / dumps under load |
Library-internal blocking (e.g. SDKs, logging sinks) |
Load tests (k6, bombardier) + dotnet-counters |
Queue length growth, thread creep |
| Slow-client tests | Sync body reads that only hurt on slow networks |
| Code review checklist | Thread.Sleep, lock + I/O, AllowSynchronousIO = true |
Our [mobile backend]'s photo-upload endpoint read the body with new StreamReader(Request.Body).ReadToEnd(), and years earlier someone had set AllowSynchronousIO = true globally to silence the exception. Whenever many users uploaded on slow mobile networks, all endpoints' latency spiked while CPU stayed low. dotnet-stack snapshots showed many threads blocked in HttpRequestStream.Read from that endpoint, each held for seconds by slow clients. I switched to ReadToEndAsync, then went further and streamed the body directly to blob storage with CopyToAsync (no full buffering), and removed the global AllowSynchronousIO. The spikes disappeared; we enabled CA1849 as an error and added a slow-client load test (throttled bandwidth) to the release pipeline.
Checks practical knowledge of web server threading.
Green flags
AllowSynchronousIO and why it defaults to false.Red flags
AllowSynchronousIO as a fix.| Work on a request thread | Verdict |
|---|---|
| Short CPU (validation, mapping, small serialisation) | Fine |
| Waiting for I/O synchronously | Bad: blocks a thread for nothing |
| Long CPU (seconds: PDFs, image processing, big reports) | Move to a bounded background queue / separate service |
The distinction matters because Task.Run doesn't fix waiting (see async-over-sync), and long CPU work needs capacity planning, not async.
// WRONG
app.Use((ctx, next) => { Audit(ctx); next(ctx).Wait(); return Task.CompletedTask; });
// RIGHT
app.Use(async (ctx, next) =>
{
await AuditAsync(ctx, ctx.RequestAborted);
await next(ctx);
});
Synchronous filter interfaces are fine for CPU logic; for anything that touches the database, cache or HTTP, implement IAsyncActionFilter / IAsyncAuthorizationFilter.
Modern providers (Microsoft.Data.SqlClient, Npgsql, MySqlConnector) implement real async I/O. Some older providers implement ...Async methods by running the sync version, which still blocks. The method name proves nothing; check docs and look at stacks under load.
app.MapPost("/photos", async (HttpRequest req, BlobContainerClient blobs, CancellationToken ct) =>
{
var blob = blobs.GetBlobClient($"{Guid.NewGuid()}.jpg");
await blob.UploadAsync(req.Body, overwrite: false, ct); // streams; no full in-memory copy
return Results.Created(blob.Uri, null);
});
Streaming with async I/O fixes both blocking and memory spikes for large payloads (configure MaxRequestBodySize deliberately).
| Version | Change |
|---|---|
| ASP.NET Core 3.0 | AllowSynchronousIO defaults to false (Kestrel, IIS, TestServer) |
| .NET 5+ | ReadFromJsonAsync / WriteAsJsonAsync helpers |
| .NET 7 | ReadToEndAsync(CancellationToken) overload |
| .NET 8+ | Analyser improvements (CA1849 broader coverage) |
| .NET 10 | Same guidance |
/upload-bad from the code example and observe InvalidOperationException.AllowSynchronousIO, load-test with a throttled client (e.g. curl --limit-rate 10k in parallel), and watch pool metrics; then switch to /upload.Thread.Sleep(200) in an endpoint, load-test, then replace with await Task.Delay(200).next(ctx).Wait() and observe its effect under load..editorconfig (dotnet_diagnostic.CA1849.severity = warning) and fix findings.using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
// Do NOT do this to silence errors:
// builder.WebHost.ConfigureKestrel(o => o.AllowSynchronousIO = true);
var app = builder.Build();
// BAD (throws by default; blocks if AllowSynchronousIO is enabled)
app.MapPost("/upload-bad", (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
string text = reader.ReadToEnd();
return text.Length;
});
// GOOD: async body reading, async delay, async lock
var gate = new SemaphoreSlim(1, 1);
app.MapPost("/upload", async (HttpRequest request, CancellationToken ct) =>
{
using var reader = new StreamReader(request.Body);
string text = await reader.ReadToEndAsync(ct);
await gate.WaitAsync(ct);
try
{
await Task.Delay(10, ct); // stands in for async I/O under a shared limit
}
finally
{
gate.Release();
}
return Results.Ok(new { Length = text.Length });
});
// GOOD: model binding / ReadFromJsonAsync are already async
app.MapPost("/orders", async (HttpRequest request, CancellationToken ct) =>
{
var order = await request.ReadFromJsonAsync<JsonElement>(ct);
return Results.Accepted(value: order.GetProperty("id").GetInt32());
});
app.Run();
AllowSynchronousIO to silence an exceptionWhat happens: o.AllowSynchronousIO = true globally.
Why it is wrong: re-enables blocking body I/O that starves the pool under load.
Do instead: async reads/writes; per-request opt-in only for a specific legacy dependency.
What happens: ToListAsync in one place, SaveChanges() in another.
Why it is wrong: sync calls block threads on database I/O.
Do instead: async variants consistently in request code.
Thread.Sleep for retries/throttlingWhat happens: retry loops sleep in endpoints.
Why it is wrong: blocks the thread for the whole delay.
Do instead: await Task.Delay or a resilience pipeline.
...Async means non-blockingWhat happens: a library's async method is trusted blindly. Why it is wrong: some implementations block internally. Do instead: check docs and stacks under load.
Request.Form synchronouslyWhat happens: var name = Request.Form["name"]; in an endpoint.
Why it is wrong: forces a synchronous read of the body.
Do instead: await Request.ReadFormAsync(ct) or [FromForm] binding.
next(...).Wait()What happens: custom middleware written synchronously.
Why it is wrong: blocks a thread for the entire downstream pipeline.
Do instead: await next(context).
What happens: IActionFilter.OnActionExecuting queries a cache synchronously.
Why it is wrong: blocking on every request.
Do instead: IAsyncActionFilter.
What happens: lock (_gate) { _cache = LoadFromDb(); }.
Why it is wrong: blocks callers and holds a lock during I/O.
Do instead: SemaphoreSlim.WaitAsync and async I/O, or refresh in the background.
What happens: every log line is sent synchronously to a remote endpoint. Why it is wrong: logging latency becomes request latency; blocks threads. Do instead: batched async sinks, reasonable log levels.
What happens: ReadToEndAsync on multi-MB bodies.
Why it is wrong: not blocking, but memory spikes and GC pressure.
Do instead: stream to storage with CopyToAsync / SDK streaming uploads.
In ASP.NET Core the rule is simple: never wait on a request thread. I enforce it with analysers, Kestrel's default sync I/O guard and load tests, and I treat any AllowSynchronousIO = true as a tracked defect.
| Control | Purpose |
|---|---|
| CA1849, VSTHRD analysers | Catch blocking calls at build time |
Kestrel AllowSynchronousIO = false |
Fail fast on sync body I/O |
| Load tests with pool metrics | Catch hidden blocking in libraries |
| Stack snapshots in incidents | Find the exact blocking frame |
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.
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.
SemaphoreSlim is a lightweight, in-process counting semaphore that supports WaitAsync, so it works with async/await. With a count of 1 it is an async lock: await gate.WaitAsync(ct); try { ... } finally { gate.Release(); }. With a count of N it limits how many async operations run at once, for example ten concurrent calls to a partner API. It is not re-entrant and has no owner, so calling it again from inside the protected section deadlocks, and every successful wait must be paired with exactly one Release in finally.
How to tell it (STAR)
Our [mobile API] slowed down whenever users uploaded photos on bad connections. The stacks showed threads blocked reading request bodies synchronously, because someone had enabled sync I/O long ago. I made the reads async, then streamed uploads straight to storage, and removed the setting. The spikes went away.
Last updated 31 Oct 2026.