dotnet · performance  ·  18 Aug 2026  ·  6 min

Your API ignores the user who gave up

Khaled Gadhami
The same five-second job: the leaky endpoint keeps working after the caller disconnects; the cancellable one stops the moment the caller gives up
COMPANION REPO · LIVE FROM GITHUB

A user opens your dashboard. The report is slow, so after a second or two they close the tab and move on with their day. Here is a question worth asking about your own API: what happens to the work that request started?

In most ASP.NET Core code I've reviewed over the years, the answer is that nothing changes. The handler keeps running, the database keeps paging results, the serializer builds a response, and Kestrel has nowhere to send it — the client is already gone. The server had that signal the whole time. The code never asked.

TEN PAGES OF WORK · THE CALLER LEAVES AT PAGE 3
/report/leaky — no token
✕ client disconnects
eight more pages loaded for nobody — ~4s of paid work, discarded
/report/cancellable — CancellationToken ct
stops at the disconnect — capacity released

The signal is already there

When the server observes a client disconnect, ASP.NET Core cancels HttpContext.RequestAborted. In a minimal API you don't even need to touch HttpContext — declare a CancellationToken parameter and the framework binds it to that same signal:

app.MapGet("/report/cancellable", async (ILogger<Program> log, CancellationToken ct) =>
{
    var total = 0;
    for (var page = 1; page <= 10; page++)
        total += await LoadPageAsync(page, log, ct); // token flows down
    return Results.Ok(new { total });
});

That parameter's cost is negligible when the caller stays. When the caller leaves, the next cancellable operation throws OperationCanceledException, the chain unwinds, and whatever downstream resource it was holding — a pooled database connection, an HttpClient connection — is released instead of staying occupied for pages nobody's waiting on.

The rule that makes it work: the token has to travel the whole path. ToListAsync(ct) on your EF Core queries, SendAsync(request, ct) on your HttpClient calls, Task.Delay(ms, ct) in your retries. One missing link and everything below it keeps running.

Behind a reverse proxy or load balancer that keeps its own connection to Kestrel open regardless of what the client does, this signal can arrive late or not at all — know your topology before you lean on it in production.

What it looks like in practice

The demo repo has two endpoints doing the same simulated work — ten pages at half a second each. Kill both requests about 1.3 seconds in:

Multiply the leaky version by every impatient user on a slow day and you are paying for a real slice of your capacity to compute answers nobody will read. Under load this is exactly the wrong behavior: slow responses make users bail, and every bail adds more wasted work to the queue that made responses slow.

The caveats, because there are real ones

Don't cancel non-idempotent writes blindly. Cancelling a read is cheap and usually safe to redo — a read with side effects (a consumed queue message, an incremented counter) is really a write wearing a read's name; treat it like one. Cancelling half of "charge the card, then record the order" is how you get support tickets. For write paths, either let the operation complete once it has started, or design the whole flow to be safely retryable before you let a token into it.

Catch it where you can say something useful. An OperationCanceledException from an aborted request is normal operation, not an error. Handle it deliberately:

catch (OperationCanceledException)
{
    log.LogInformation("report stopped early — caller left, work released");
    throw; // let the framework finish aborting the request
}

A catch this broad also sees more than the caller leaving: a downstream HttpClient.Timeout throws TaskCanceledException, which is an OperationCanceledException, and this snippet would log that as "caller left" too. Even ct itself isn't purely a caller signal — RequestAborted also fires for server-initiated cancellation: an explicit HttpContext.Abort() call, or the request-timeout middleware timing the request out (which cancels the token without calling Abort(), so the app can still produce its own response). So "caller left" really means "caller left, or the server gave up on them." Filter on the token you actually care about once more than one cancellation source is in play — the caveat below on the server-side ceiling shows the pattern.

Cancellation is cooperative. A tight CPU loop with no awaits will ignore the token unless you check it yourself (ct.ThrowIfCancellationRequested() at sensible intervals). A library that accepts a token isn't obliged to react instantly — ToListAsync(ct) stops as fast as the provider's wire protocol lets it, not the instant you cancel — and some libraries accept a token and never check it at all. Cancellation is a request, not a kill switch.

Give yourself a ceiling too. The caller's patience isn't the only budget. Link the request token to a server-side timeout and you're protected from both the impatient client and the runaway query — requestAborted below is just another name for the same bound CancellationToken as ct above, from a handler that named its parameter for clarity:

using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
cts.CancelAfter(TimeSpan.FromSeconds(3));
// pass cts.Token down; distinguish the two cases when catching

The demo's /report/bounded endpoint shows the full pattern, including telling "caller left" apart from "ceiling hit".

Don't let the token outlive the request. RequestAborted (or a bound CancellationToken) is scoped to this request, not to whatever work you started from it. If the caller leaves while your background work is still running, that work gets cancelled with it — even work you meant to survive on its own. And HttpContext itself isn't safe to keep touching from a background task at all: Microsoft's own guidance is that it "must not be captured outside of the request flow." Give background work its own token (IHostApplicationLifetime. ApplicationStopping, say) and its own copy of whatever data it needs — not a live reference to the request's. The same lifetime rule applies to a linked CancellationTokenSource like the one above: it has to live as long as the work holding its token, so a using that disposes it when the handler returns is the wrong shape for anything you don't await before returning.

The habit

New endpoint, new service method, new repository call: add the CancellationToken parameter first and pass it down. It's one of the cheapest reliability habits in .NET — the signal is free, the parameter's cost is negligible, and the capacity you get back is real.