# Synchronous Operations Are Disallowed in ASP.NET Core: Causes and Fixes

You upgraded a service, deployed it, and a single endpoint started throwing this:

```plaintext
System.InvalidOperationException: Synchronous operations are disallowed.
Call ReadAsync or set AllowSynchronousIO to true instead.
```

The "Synchronous operations are disallowed" error in ASP.NET Core is not a bug in your code so much as the framework refusing to let you do something it knows will hurt you under load. There is a one-line setting that makes it go away, every search result mentions it, and in production I've watched teams reach for it and then spend the following month debugging latency spikes they never connected back to that line. The genuinely useful part of this error is what it is telling you, so this walkthrough covers the causes, the correct fix for each one, and the buffering trick that lets you keep sync-only libraries without switching the setting on at all.

The deeper patterns here - async all the way down, request buffering, and the diagnostics that prove which one bit you - are worked through with runnable code and load-test output on [Patreon](https://www.patreon.com/CodingDroplets).

## What the Error Actually Means

Since ASP.NET Core 3.0, Kestrel refuses synchronous reads and writes on the request and response body by default. `AllowSynchronousIO` is `false`, and any code that calls a blocking `Read`, `Write`, or `Flush` on those streams gets an `InvalidOperationException` instead.

The reason is thread pool starvation, and Microsoft's [ASP.NET Core best practices](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/best-practices) lead with avoiding blocking calls for exactly this reason. A synchronous read on a network stream blocks a thread pool thread for the entire time the client takes to send its data - which, for a mobile client on a bad connection, can be seconds. Under concurrency you run out of threads, the pool injects new ones slowly, and every request in the process gets slower at once. The failure looks like a mysterious latency cliff rather than an obvious error, which is exactly why the framework now fails fast instead. We traced that exact cascade in [ASP.NET Core thread pool starvation: root cause and fix](https://codingdroplets.com/aspnet-core-threadpool-starvation-production-fix).

You will see two variants of the message. `Call ReadAsync` means something read the request body synchronously. `Call WriteAsync` means something wrote to the response body synchronously. The distinction narrows the search considerably.

## Cause 1: Reading the Request Body Synchronously

The classic form, usually inside custom middleware or a legacy controller action:

```csharp
// Throws: synchronous read on the request stream
using var reader = new StreamReader(HttpContext.Request.Body);
var json = reader.ReadToEnd();
```

**The fix** is the async equivalent, awaited all the way up the call chain:

```csharp
using var reader = new StreamReader(HttpContext.Request.Body);
var json = await reader.ReadToEndAsync();
```

If you are deserializing, skip the intermediate string entirely and use `JsonSerializer.DeserializeAsync<T>(HttpContext.Request.Body)`, which reads and parses in one pass without materialising the whole payload.

## Cause 2: Accessing Request.Form Without Awaiting

This one surprises people because there is no visible stream:

```csharp
var file = HttpContext.Request.Form.Files[0];   // synchronous read under the hood
```

The `Form` property parses the body on first access, and it does so synchronously. **The fix** is `await HttpContext.Request.ReadFormAsync()`, then use the returned collection. Once the form has been read asynchronously, later access to `Request.Form` is served from the parsed result and is safe.

## Cause 3: Writing or Flushing the Response Synchronously

Anything that hands `Response.Body` to a writer that flushes on dispose will trip this:

```csharp
using var writer = new StreamWriter(HttpContext.Response.Body);
writer.Write(csv);        // throws on flush
```

**The fix** is `await writer.WriteAsync(csv)` followed by `await writer.FlushAsync()`. Be careful with `using` here: a synchronous `Dispose` on a `StreamWriter` flushes synchronously, so use `await using` so the async disposal path runs instead. That detail catches people who converted every visible call and still see the exception.

## Cause 4: A Third-Party Library You Cannot Change

XML serializers, older SOAP stacks, report generators, CSV writers, and some APM agents were written before this restriction existed and only expose synchronous APIs against a `Stream`. You cannot rewrite them, and the standard advice is to enable `AllowSynchronousIO` globally.

**Do not do that.** There is a better fix, and it is the part most write-ups skip: **buffer through a** `MemoryStream`**.** A `MemoryStream` performs no real I/O, so synchronous operations on it block nothing.

For reading, copy the request body asynchronously first, then hand the buffer to the sync-only API:

```csharp
using var buffer = new MemoryStream();
await HttpContext.Request.Body.CopyToAsync(buffer, ct);
buffer.Position = 0;
var model = (MyType)_xmlSerializer.Deserialize(buffer);   // sync, but on memory
```

For writing, invert it: let the library write into a `MemoryStream`, then copy that to the response asynchronously. You keep the library, you keep the default setting, and no thread blocks on a socket.

The trade-off is real and you should size it deliberately: you are now holding the whole payload in memory. That is fine for a 50 KB XML document and wrong for a 500 MB upload. For large payloads, find a streaming API or accept the setting change on that endpoint alone.

## Cause 5: The Endpoint Genuinely Needs Synchronous I/O

Sometimes buffering is not viable and you have to allow it. **Do it per request, never globally.** The `IHttpBodyControlFeature` feature lets you opt one request out:

```csharp
var bodyControl = HttpContext.Features.Get<IHttpBodyControlFeature>();
if (bodyControl is not null)
    bodyControl.AllowSynchronousIO = true;
```

Put that at the top of the specific endpoint or in middleware scoped to one route. The blast radius is one request instead of your whole application.

For completeness, the global switches - which you should treat as a last resort - differ by server:

```csharp
builder.WebHost.ConfigureKestrel(o => o.AllowSynchronousIO = true);              // Kestrel
builder.Services.Configure<IISServerOptions>(o => o.AllowSynchronousIO = true);  // IIS in-process
```

Note that these are separate settings. A fix that works locally under Kestrel and fails in IIS almost always means only one of them was configured. Microsoft documents both on the [Kestrel options reference](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.server.kestrel.core.kestrelserveroptions.allowsynchronousio).

## How Do You Find Which Line Threw It?

Read the stack trace from the bottom up and look for the first frame that is your code or a third-party package rather than framework internals. That frame owns the stream.

Two things speed this up when the trace is unhelpful:

*   **The message variant tells you the direction.** `ReadAsync` means request body, `WriteAsync` means response body. That halves the surface immediately.
    
*   **Turn the setting on temporarily in a non-production environment with a first-chance exception breakpoint** on `InvalidOperationException`. You see the exact call site once, then you turn it back off and fix it properly. This is a diagnostic technique, not a fix.
    

If the culprit is a package rather than your code, check whether a newer version exposes async overloads before you reach for buffering. Many libraries added them precisely because of this change.

## How to Avoid It Coming Back

*   **Never enable** `AllowSynchronousIO` **globally.** The moment it is on, new blocking code enters the codebase silently and you lose the framework's early warning.
    
*   **Ban blocking calls in review.** `.Result`, `.Wait()`, `ReadToEnd()`, and `Flush()` on a request or response stream are all the same category of mistake. Our list of [common async and await mistakes in ASP.NET Core](https://codingdroplets.com/async-await-mistakes-aspnet-core) covers the wider family.
    
*   **Use** `await using` **for anything wrapping a response stream**, so disposal flushes asynchronously.
    
*   **Load test the endpoint you "fixed".** Sync I/O does not fail under a single request; it fails at concurrency. A green integration test proves nothing here.
    
*   **Watch thread pool metrics.** A rising queue length with low CPU is the signature of blocked threads, and it is visible long before users complain.
    

## FAQ

### Should I just set AllowSynchronousIO to true in ASP.NET Core?

Only as a scoped, temporary measure. Enabling it globally restores the exact behaviour the framework disabled because it causes thread pool starvation under load, and it silences the warning for all future code as well as the line you were fixing. If you must allow it, do it per request through `IHttpBodyControlFeature` so one endpoint is affected rather than the whole application.

### Why did this error only appear after upgrading ASP.NET Core?

Because synchronous I/O on the request and response body was allowed by default before ASP.NET Core 3.0 and disallowed after it. The blocking code was always there and was always harming throughput; the upgrade only made it visible. Treat the exception as a pre-existing defect that surfaced, not as a regression the upgrade introduced.

### How do I fix "Synchronous operations are disallowed" with a library that has no async API?

Buffer through a `MemoryStream`. Copy the request body into memory with `CopyToAsync`, hand the `MemoryStream` to the synchronous library, and for responses do the reverse. Synchronous operations on a memory stream block no threads because no real I/O happens, so you keep the default setting. Watch payload size, since you are trading memory for thread safety.

### Does this error happen with IIS as well as Kestrel?

Yes, and they are configured separately. Kestrel uses `KestrelServerOptions.AllowSynchronousIO`, IIS in-process hosting uses `IISServerOptions.AllowSynchronousIO`, and HTTP.sys has its own equivalent. A fix applied to only one of them produces the confusing situation where the endpoint works locally and fails when deployed.

### Can accessing HttpContext.Request.Form cause this exception?

Yes. Reading the `Form` property parses the request body synchronously on first access, which trips the same guard as an explicit stream read. Call `await Request.ReadFormAsync()` first; afterwards the parsed collection is cached and further access to `Request.Form` is safe.

### Is buffering the request body into memory safe for large uploads?

No, and this is the main limit of the technique. Buffering holds the entire payload in memory, so a few concurrent large uploads can drive memory pressure and garbage collection pauses. For large bodies, use a genuinely streaming API, write to a temporary file, or scope `AllowSynchronousIO` to that single endpoint with a request size limit in place.

* * *

## About the Author

I'm Celin Daniel, Co-founder of [Coding Droplets](https://codingdroplets.com/). I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

*   GitHub: [codingdroplets](http://github.com/codingdroplets/)
    
*   YouTube: [Coding Droplets](https://www.youtube.com/@CodingDroplets)
    
*   Website: [codingdroplets.com](https://codingdroplets.com/)
