Middleware
Middleware has the same shape as ASP.NET Core’s: do work, call next or don’t, do more work.
app.Use(async (ctx, next) =>{ var sw = Stopwatch.StartNew(); await next(ctx); logger.LogInformation("{Path} -> {Status} in {Ms}ms", ctx.Request.Path, ctx.Response.StatusCode, sw.ElapsedMilliseconds);});Not calling next short-circuits everything below it, including routing.
As a class
Section titled “As a class”Once middleware has dependencies, its own tests, or more than a screenful of code, it wants to be a type:
public sealed class ApiKeyMiddleware(IKeyStore keys) : IHttpMiddleware{ public async ValueTask InvokeAsync(HttpContext context, RequestDelegate next) { if (!keys.IsValid(context.Request.Headers.GetFirst("X-Api-Key"))) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
await next(context); }}Two ways to add it:
builder.Services.AddSingleton<ApiKeyMiddleware>();app.Use<ApiKeyMiddleware>(); // resolved per request, from the request's own scope
app.Use(new ApiKeyMiddleware(store)); // an instance you already haveUse<T>() resolves from the request scope, so a middleware registered Scoped gets the same
instances as everything else handling that request, and one registered Singleton costs a dictionary
lookup. Constructing it any other way would mean reflection, which is the one thing this server does
not do.
Use(IHttpMiddleware) uses the same instance for every request, so it must be thread-safe and
hold no per-request state.
Ordering
Section titled “Ordering”Middleware runs in registration order, wrapping routing and the terminal handler:
Use(A) ─┐ Use(B) ─┐ routing ─┐ UseAfterRouting(C) ─┐ endpoint handlerThe pipeline is composed once, the first time the server starts or serves a connection. Registering
middleware after that throws — and RestartAsync does not recompose it. Routes, by contrast, can
change at any time.
RequestDelegate returns ValueTask because most handlers complete synchronously and should not
allocate a Task to say so.
Use vs UseAfterRouting
Section titled “Use vs UseAfterRouting”UseAfterRouting runs after the router has chosen an endpoint, wrapping only the endpoint’s own
invocation. The difference is ctx.Endpoint: there it is populated, so the middleware can read the
endpoint’s metadata and decide accordingly.
app.UseAfterRouting(async (ctx, next) =>{ if (ctx.Endpoint?.GetMetadata<AuditMetadata>() is { } audit) await auditLog.RecordAsync(audit.Name, ctx.User);
await next(ctx);});That is what authorization needs: [Authorize] is a property of the endpoint, and there is no
endpoint before routing has run. Requests that matched nothing skip this stage entirely and go
straight to the 404 or 405.
The order that matters
Section titled “The order that matters”For an app using most of the built-ins, this is the order that is actually correct:
app.Use<RequestTimingMiddleware>(); // outermost: sees everything, including rejections
app.UseCors(); // a preflight carries no credentials — authenticating it would 401 the // browser's question and the real request would never be sentapp.UseRateLimiter(); // before routing, so a throttled request costs nothing beyond parsingapp.UseIpFilter();
app.UseResponseCompression();
app.UseAuthentication(); // before routing — identity does not depend on the endpointapp.UseAuthorization(); // after routing — what is required is metadata on the endpoint
app.UseSessions();app.UseStaticFiles(); // falls through to routing when no file matchesUseAuthorization() registers itself as after-routing middleware for you; the rest are ordinary
Use.
Writing to the response from middleware
Section titled “Writing to the response from middleware”Headers are flushed on the first body write, and mutating them afterwards throws. Middleware that
wants to add a header around a handler registers a callback instead of setting it after next:
app.Use((ctx, next) =>{ ctx.Response.OnStarting(() => { ctx.Response.Headers["X-Served-By"] = "Shiny"; return ValueTask.CompletedTask; });
return next(ctx);});OnStarting runs immediately before the status line and headers go to the wire — the last chance to
change either. ctx.Response.HasStarted tells you whether that moment has already passed.
Reading the bodies
Section titled “Reading the bodies”Middleware sits between the client and the handler, so it is the only place that can see both bodies of an exchange — which is what a traffic recorder, a request logger or an audit trail is made of. Each direction has its own seam.
Inbound, Request.Body is a forward-only stream off the connection, so reading it consumes it.
Assigning a rewound copy back is what makes it readable twice — once by you, then again by the
handler, which never knows the difference. Assigning also drops any BodyReader already handed out,
so the stream and the reader cannot disagree about where the body starts.
app.Use(async (ctx, next) =>{ if (ctx.Request.HasBody) { var buffered = new MemoryStream(); await ctx.Request.Body.CopyToAsync(buffered, ctx.RequestAborted); buffered.Position = 0;
Log(buffered.ToArray()); ctx.Request.Body = buffered; // handed on rewound }
await next(ctx);});Outbound, every write funnels through the response’s IResponseBodyControl, whichever of Body,
BodyWriter or the convenience helpers the handler reached for. Wrap the control the response is
currently bound to and bind the wrapper, and you see all of them:
sealed class TeeBodyControl(IResponseBodyControl inner, Stream capture) : IResponseBodyControl{ Stream? stream; PipeWriter? writer;
public bool HasStarted => inner.HasStarted;
public Stream Stream => this.stream ??= new TeeStream(inner.Stream, capture);
// built over Stream, not over inner.Writer, so both write paths meet in one place public PipeWriter Writer => this.writer ??= PipeWriter.Create( this.Stream, new StreamPipeWriterOptions(leaveOpen: true) );
public ValueTask StartAsync(CancellationToken ct) => inner.StartAsync(ct); public ValueTask CompleteAsync(CancellationToken ct) => inner.CompleteAsync(ct);
public ValueTask FlushAsync() => this.writer?.FlushAsync().AsValueTask() ?? default;}
app.Use(async (ctx, next) =>{ var capture = new MemoryStream(); var tee = new TeeBodyControl(ctx.Response.BodyControl, capture); ctx.Response.Bind(tee);
try { await next(ctx); } finally { await tee.FlushAsync(); Log(ctx.Response.StatusCode, capture.ToArray()); }});This is the same seam response compression inserts itself through.
Per-request state
Section titled “Per-request state”ctx.Items is a scratch dictionary allocated on first use, for passing state between middleware in
one request. Anything with a lifetime beyond the request belongs in a session
or a singleton service.


