Skip to main content

Command Palette

Search for a command to run...

ASP.NET Core Response Already Started: Causes and Fixes

Updated
9 min readView as Markdown
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, 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 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

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:

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.

// 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:

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:

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.

// 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:

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:

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 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 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

// 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. 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.

More from this blog

C

Coding Droplets

275 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.