Cancellation in a Web Request
HttpContext.RequestAborted cancels when the client hangs up, and tells you which work is safe to abandon.
CancellationToken token = httpContext.RequestAborted;ASP.NET Core gives every request its own token. It is called RequestAborted and it hangs off HttpContext, the object that holds everything about one request and the response being built for it. The token is cancelled when the connection carrying that request drops: the browser tab closed, the user pressed stop, the mobile network died, the load balancer gave up waiting.
Getting the token
Two forms, one token. Longhand, take HttpContext as a handler parameter and read context.RequestAborted. Shorthand, declare a CancellationToken parameter on the handler; ASP.NET Core treats that type specially and binds it to HttpContext.RequestAborted for you. They are the same value.
What to stop
Everything whose only purpose was the response. Nobody will read it now, so pass the token into database queries, calls to other services and long loops, and let them unwind. On a busy service this is real capacity returned rather than tidiness.
What not to stop
Work that must either finish or be undone. A payment, a row in an outbox, a file being written. Those must not stop at whatever line the token happened to reach. Give them CancellationToken.None, or a token with its own deadline, and let them complete.
RequestAborted is not a timeout. It fires only on disconnect, so a client content to wait forever gives you no deadline at all. Add your own with a linked source.
Example
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
// Shorthand: a CancellationToken parameter is bound to HttpContext.RequestAborted.
app.MapGet("/slow", async (CancellationToken cancellationToken) =>
{
try
{
// Stands in for a slow query that accepts a token.
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
return Results.Ok(new { status = "finished" });
}
catch (OperationCanceledException)
{
// The client is gone, so this status reaches logs and metrics, not them.
app.Logger.LogInformation("Client disconnected from /slow");
return Results.StatusCode(499);
}
});
// Longhand: the same token, read from HttpContext by hand.
app.MapGet("/also-slow", async (HttpContext context) =>
{
await Task.Delay(TimeSpan.FromSeconds(5), context.RequestAborted);
return Results.Ok(new { status = "finished" });
});
app.Run();When to use it
- Freeing database and CPU time when users navigate away from a slow dashboard.
- Making sure a payment still commits when the caller's connection drops mid-request.
- Capping how long a search endpoint may run, separately from client disconnects.
- Keeping client disconnects out of your error logs by handling them as their own case.
More examples
Read with the token, write without it
The two halves of a write endpoint want opposite treatment, and this is the single most useful distinction in the lesson. Reads and validation are disposable. The commit is not: a half-saved order is worse than a slow one, and the client has no way to find out which happened. If the write itself needs a bound on how long it may take, give it a separate source with CancelAfter rather than reusing the connection's token.
app.MapPost("/orders", async (HttpContext context) =>
{
// Reading the body is pointless once the client has gone, so this part
// honours RequestAborted.
Order? order = await context.Request.ReadFromJsonAsync<Order>(context.RequestAborted);
if (order is null)
{
return Results.BadRequest();
}
// The write must finish or fail on its own terms. A client hanging up
// mid-write must not leave half an order in the database.
await SaveOrderAsync(order, CancellationToken.None);
return Results.Accepted();
});Disconnect and a server-side deadline together
RequestAborted answers "has the client gone?" and never "has this taken too long?", so a well-behaved endpoint needs both. The linked source produces one token meaning either, and the exception filter separates them again so the two cases get different status codes. Checking RequestAborted rather than the linked source in the filter is deliberate: the linked token is cancelled in both cases, so it cannot tell them apart.
app.MapGet("/search", async (string q, HttpContext context) =>
{
// Cancel if the client disconnects, and in any case after three seconds.
using CancellationTokenSource cts =
CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
cts.CancelAfter(TimeSpan.FromSeconds(3));
try
{
string[] hits = await SearchAsync(q, cts.Token);
return Results.Ok(hits);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// Nobody is listening. Stop and record it.
return Results.StatusCode(499);
}
catch (OperationCanceledException)
{
// Our own deadline fired. The client is still there, so answer it.
return Results.StatusCode(504);
}
});Checking between batches
Loops are where abandoned requests quietly cost the most, because each iteration starts new work long after anyone stopped caring. Checking once per batch is cheap and stops the loop within one batch of the disconnect. The returned result goes nowhere when the client has already left, and that is fine: the point of the check is the database and CPU time you did not spend, not the response.
app.MapGet("/export", async (CancellationToken cancellationToken) =>
{
int written = 0;
foreach (string[] batch in GetBatches())
{
if (cancellationToken.IsCancellationRequested)
{
// Half an export is no use to anyone, and the work costs the
// database real time. Stop building it.
break;
}
await WriteBatchAsync(batch, cancellationToken);
written += batch.Length;
}
return Results.Ok(written);
});
Discussion