# ASP.NET Core Response Already Started: Causes and Fixes

If you have hit `System.InvalidOperationException: StatusCode cannot be set because the response has already started` — or the variant `Headers are read-only, response has already started` — you are looking at one of the more disorienting runtime errors in ASP.NET Core. The stack trace rarely points to the offending line directly, and the error can appear in middleware that looked fine in isolation.

In production I have seen this one bite teams when they add security headers middleware after streaming starts, or when an exception handler tries to write a 500 response to a connection that already flushed its first bytes. The full pattern - with every concrete cause and a working fix for each - is on [Patreon](https://www.patreon.com/CodingDroplets), alongside source code for a complete middleware pipeline with correct ordering and a custom exception handler that checks `HasStarted` before touching the response.

The tricky part is not any one fix in isolation - it is understanding why ASP.NET Core enforces this rule and what that means for where you read vs write the response in a real API. That is exactly what the [Zero to Production course](https://aspnetcoreapi.codingdroplets.com/) walks through: the full middleware pipeline, exception handling, and how these pieces fit together in a running codebase.

[![ASP.NET Core Web API: Zero to Production](https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg align="center")](https://aspnetcoreapi.codingdroplets.com/)

## What "Response Has Already Started" Actually Means

HTTP works in one direction once the status line and headers leave the server. In ASP.NET Core (Kestrel, IIS In-Process, or HTTP.sys), the response is a stream. The moment any byte is flushed to the client - whether that is the status code, a response header, or the first byte of the body - ASP.NET Core locks the response header collection. Any subsequent attempt to set `context.Response.StatusCode`, call `context.Response.Headers.Add(...)`, or write via `context.Response.WriteAsync(...)` in certain sequences will throw.

Kestrel's internal check looks roughly like this:

```csharp
if (_headerFlags.HasFlag(HttpResponseHeaderFlags.HasStarted))
    ThrowResponseAlreadyStartedException(nameof(StatusCode));
```

`HttpContext.Response.HasStarted` is the public surface for this flag. Checking it before writing is the foundational fix that applies across every cause listed below.

## Common Causes and How to Fix Each One

### Cause 1: Writing the Response Body Before Setting the Status Code

This is the most common mistake in custom exception-handling middleware. The developer calls `context.Response.WriteAsync(...)` or `context.Response.Body.WriteAsync(...)` first, then tries to set the status code.

```csharp
// Wrong - body write starts the response
await context.Response.WriteAsync("Error occurred");
context.Response.StatusCode = 500; // throws here
```

**Fix:** Set `StatusCode` (and any response headers) before calling any write method:

```csharp
context.Response.StatusCode = 500;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsync(payload);
```

### Cause 2: Exception Thrown Inside a Streaming Endpoint

When an endpoint streams a response using `IAsyncEnumerable<T>` or calls `Response.WriteAsync(...)` in a loop, the headers and status code have already been sent by the time the first item is written. If an exception occurs mid-stream, any outer middleware that tries to set a 500 status will fail.

**Fix:** The correct approach is `context.Response.HasStarted`. In global exception middleware, always guard the rewrite:

```csharp
catch (Exception ex)
{
    _logger.LogError(ex, "Unhandled exception");
    if (!context.Response.HasStarted)
    {
        context.Response.StatusCode = 500;
        await context.Response.WriteAsync("Internal server error");
    }
    // If HasStarted is true, you cannot change the response -
    // log only, connection will close with partial data
}
```

The `HasStarted` guard is non-negotiable in any custom exception middleware. Without it, you get a second exception thrown inside your error handler - which makes debugging significantly harder.

### Cause 3: Middleware Setting Response Headers on the Way Out

A common middleware pattern is to add security or custom headers after calling `await _next(context)`. This works only while headers have not been flushed. Kestrel flushes headers at the first body write, so if the next middleware in the pipeline writes anything, your post-`_next` headers will land after the flush.

```csharp
// This silently fails or throws if inner middleware already wrote the body
await _next(context);
context.Response.Headers["X-Custom"] = "value"; // may throw
```

**Fix:** Use `context.Response.OnStarting(...)` to register a callback that runs just before the response starts:

```csharp
context.Response.OnStarting(() =>
{
    context.Response.Headers["X-Custom"] = "value";
    return Task.CompletedTask;
});
await _next(context);
```

`OnStarting` guarantees your callback fires before any byte leaves Kestrel, regardless of where in the pipeline the first write happens. This is the correct pattern for security headers middleware.

### Cause 4: Calling Response.Redirect After Body Has Started

`Response.Redirect(url)` sets the status code to 302 and the `Location` header. If called after anything has already been written to the response body, it throws.

**Fix:** Redirects must happen before any response body is written. If you are conditionally redirecting inside an action or middleware, check whether a response has already started:

```csharp
if (!context.Response.HasStarted)
    context.Response.Redirect("/error");
```

Better: restructure the logic so the redirect decision happens at the start of request processing, not after a partial response.

### Cause 5: Double-Write in UseExceptionHandler Configuration

`app.UseExceptionHandler("/error")` re-runs the pipeline through the `/error` endpoint. If the original endpoint started writing a response before throwing, the exception handler will try to reset `StatusCode` on an already-started response.

This is subtle: `UseExceptionHandler` internally checks `HasStarted` and will swallow some of these cases, but custom error endpoints inside the handler can still trigger it if they do not guard themselves.

**Fix:** Always place `UseExceptionHandler` first in the middleware pipeline (or very close to the top), and ensure your exception endpoints check `HasStarted` before writing. The placement matters because middleware runs in order - if your endpoint writes before the exception handler can intercept, there is no recovery path. The [ASP.NET Core Middleware Pipeline Checklist](https://codingdroplets.com/aspnet-core-middleware-pipeline-checklist-dotnet-teams) covers the full correct ordering for production APIs.

### Cause 6: IExceptionHandler Writing After Response Starts

If you are using `IExceptionHandler` (.NET 8+), the framework calls `TryHandleAsync` only when the response has not started. However, if you have multiple `IExceptionHandler` implementations and the first one partially writes before returning `false`, the second one will encounter a started response.

**Fix:** A single `IExceptionHandler` should either handle the exception fully (write complete response + return `true`) or not touch the response at all (return `false`). Never partially write and then return `false`.

## What Cannot Be Fixed

If the response has already started and you are mid-stream, you cannot retroactively change the status code. The best you can do is:

1.  Log the error with full context
    
2.  Abort the connection (`context.Abort()`) if the partial response would cause data corruption on the client
    
3.  Design your streaming endpoints to not throw after the first write, by validating all inputs before streaming begins
    

This is something we ran into with a report-generation endpoint at Coding Droplets - the stream started successfully, then a DB read failed halfway through. The client received a 200 with truncated JSON. The real fix was moving validation and DB access to a pre-streaming phase, not patching the exception handler. If you want a fuller picture of how exception handling fits into the middleware pipeline, the [ASP.NET Core Global Exception Handling guide](https://codingdroplets.com/asp-net-core-global-exception-handling-iexceptionhandler-vs-middleware-vs-filters-enterprise-decision-guide) on Coding Droplets covers the IExceptionHandler vs middleware vs filters decision in depth.

## How to Diagnose It When the Stack Trace Is Unhelpful

The stack trace usually points into Kestrel internals, not your code. To find the actual source:

1.  **Enable structured logging at Debug level** for `Microsoft.AspNetCore.Server.Kestrel` - it logs when the response starts, with the request path
    
2.  **Add** `context.Response.OnStarting(...)` **logging** in a diagnostic middleware registered early in the pipeline - log the call stack when it fires
    
3.  **Use the** `Response.HasStarted` **flag** at each middleware boundary to narrow down which step is first to flush
    

```csharp
// Diagnostic-only: log when response starts
context.Response.OnStarting(() =>
{
    _logger.LogDebug("Response started at: {StackTrace}", 
        Environment.StackTrace);
    return Task.CompletedTask;
});
```

Remove this in production - stack trace capture is expensive. Use it as a targeted debugging tool.

## FAQ

**Why does this error say "StatusCode cannot be set" in some cases and "Headers are read-only" in others?** Both are the same underlying condition - the response has started. The exact message depends on which property you tried to modify. `StatusCode` and `Headers` both become read-only at the same point: when Kestrel starts flushing the response.

**Does** `UseExceptionHandler` **protect against this automatically?** Partially. `UseExceptionHandler` checks `HasStarted` before re-executing the error route. But if your error endpoint itself does not check `HasStarted`, or if you have custom exception middleware that writes before `UseExceptionHandler` can intercept, you still get the error. Defence-in-depth means checking `HasStarted` in every code path that writes to the response.

**Can** `OnStarting` **callbacks throw?** Yes. If an `OnStarting` callback throws, the exception propagates through `await _next(context)`. Guard the callback body, especially if it calls external services.

**Is this different on IIS In-Process vs Kestrel?** The behaviour is consistent across hosting models in .NET 8+ - all use the same `HttpContext` abstraction. The specific internal class throwing the exception changes (Kestrel vs HTTP.sys vs IIS In-Process), but the surface-level error and the fixes are identical.

**How do I prevent this in streaming REST endpoints?** Separate the concerns: perform all validation, authorization, and data-access setup before writing the first byte. Once you call the first `await` on the response stream, treat the response as immutable from an error-handling perspective - errors at that point require logging and connection management, not status code changes.

**Does this affect minimal APIs differently from controllers?** No - both go through the same `HttpContext` pipeline. The middleware ordering rules and `HasStarted` semantics are identical. The error surfaces in different stack traces, but the root causes and fixes are the same.

* * *

## 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/)
