Skip to content
Katabench
Try free
7 min read The Katabench team

CancellationToken in ASP.NET Core: stop doing work nobody wants

A disconnected client does not make your database query disappear. Learn how RequestAborted flows through ASP.NET Core, EF Core, and HttpClient, where cancellation should stop, and how to test it.

A user opens a report, waits two seconds, and closes the tab. The browser is gone. The request is over from the user's point of view. But the server may still be joining five tables, allocating a large result, and waiting on two downstream APIs for a response that nobody can receive.

That is the quiet waste CancellationToken is meant to stop. It is not an async decoration and it does not cancel anything by itself. It is a signal that has to travel from the HTTP connection to every operation that can abandon work safely. Miss one hop and the chain ends there.

The token ASP.NET Core already gives you

Every request has a cancellation signal at HttpContext.RequestAborted. ASP.NET Core triggers it when the underlying request is aborted by the client or server. Minimal APIs can bind that signal directly to a CancellationToken parameter:

app.MapGet("/reports/{id:guid}", async (
    Guid id,
    ReportService reports,
    CancellationToken cancellationToken) =>
{
    var report = await reports.BuildAsync(id, cancellationToken);
    return report is null ? Results.NotFound() : Results.Ok(report);
});

Controller actions get the same behavior. A CancellationToken action parameter is the request token; you do not need to reach into HttpContext just to retrieve it.

The endpoint is only the first link. This implementation still ignores cancellation:

public Task<Report?> BuildAsync(Guid id, CancellationToken cancellationToken)
{
    // The parameter exists, but the query never receives it.
    return _db.Reports
        .Include(x => x.Lines)
        .SingleOrDefaultAsync(x => x.Id == id);
}

The signature looks responsible while the actual I/O keeps running. Pass the token at every async boundary:

public async Task<Report?> BuildAsync(
    Guid id,
    CancellationToken cancellationToken)
{
    var report = await _db.Reports
        .AsNoTracking()
        .Include(x => x.Lines)
        .SingleOrDefaultAsync(x => x.Id == id, cancellationToken);

    if (report is null)
        return null;

    var rates = await _ratesClient.GetFromJsonAsync<RateTable>(
        "/current-rates",
        cancellationToken);

    return Report.Calculate(report, rates!);
}

EF Core can now ask its database provider to cancel the command, and HttpClient can abandon the downstream request. Cancellation is cooperative, so the operation decides how quickly it can stop; the token does not kill a thread or roll back arbitrary code.

HTTP request

RequestAborted

Endpoint

CancellationToken

Application

pass it through

EF Core / HTTP

cancel the I/O

Miss one boundary and the signal stops there. Everything downstream keeps working.
Cancellation is a chain, not a switch at the controller. Every asynchronous boundary has to carry the same signal.

Cancellation only saves work when every layer agrees to carry the signal. A token accepted and ignored is the asynchronous version of a smoke alarm with no battery.

Propagate, do not replace

A common mistake is accepting the caller's token and then creating an unrelated timeout token:

using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _client.SendAsync(request, timeout.Token);

Now the five-second deadline works, but closing the browser does not. Link the two reasons to stop:

using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
    cancellationToken,
    timeout.Token);

await _client.SendAsync(request, linked.Token);

The operation ends when either the request disappears or the dependency exceeds its own budget. Keep those meanings distinct in logs: an abandoned request is routine; a dependency timeout is a service-health signal. Both may surface as OperationCanceledException, so check which token was triggered before labeling the event.

CPU work has to cooperate too

Database and HTTP libraries already know how to observe a token. Your own CPU-bound loop does not. If report generation spends several seconds grouping or transforming data after the I/O completes, the request can still burn a core after its caller leaves.

Check at a sensible boundary inside long-running work:

for (var index = 0; index < rows.Count; index++)
{
    if ((index & 1023) == 0)
        cancellationToken.ThrowIfCancellationRequested();

    totals.Add(rows[index].AccountId, rows[index].Amount);
}

Checking every 1,024 iterations keeps the cancellation delay bounded without adding a branch to every tiny operation. The right interval depends on how expensive an iteration is. For a loop that compresses files, one check per file may be enough; for cryptographic or parsing work, check between chunks. Measure both responsiveness and throughput instead of copying the number blindly.

Avoid Task.Run as a cancellation adapter. Passing a token to Task.Run can prevent queued work from starting, but it cannot interrupt synchronous work once the delegate is running. The delegate still has to inspect the token. For an old callback API, token.Register can bridge cancellation to the API's own abort method, but dispose the registration with the operation so callbacks do not accumulate on a long-lived token.

Cancellation also does not undo results already produced. If a loop mutates shared state before it throws, either keep the mutation private until completion or make the partial state explicitly discardable. Stopping quickly is useful only when the stopped operation leaves a state the caller can understand.

Cancellation has a point of no return

Not every line should honor cancellation forever. Before a side effect commits, stopping usually saves resources and leaves the system unchanged. After an irreversible side effect, stopping in the middle can create a lie.

Imagine an order endpoint that charges a card, stores the order, and sends a receipt. If the client disconnects after the charge succeeds, blindly throwing at the next cancellationToken.ThrowIfCancellationRequested() can skip persistence and leave a charge with no order. The request no longer wants a response, but the business operation now needs to reach a consistent state.

The boundary should be deliberate:

  1. Honor request cancellation while validating and loading data.
  2. Once the durable operation begins, finish its consistency-critical portion.
  3. Move follow-up work to reliable asynchronous processing when possible.

This is also why cancellation is not a substitute for idempotent API design. A client may retry because it never saw the result, even though your server completed the command. Cancellation saves avoidable work; idempotency makes uncertain outcomes safe.

Do not turn normal cancellation into an error storm

Most well-behaved async APIs throw OperationCanceledException when their token is canceled. That does not automatically mean a server fault. If global exception handling records every canceled request at error level, dashboards become a catalog of users closing tabs.

Handle expected cancellation narrowly:

try
{
    return await reports.BuildAsync(id, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
    logger.LogDebug("Report {ReportId} canceled by the request", id);
    throw;
}

Do not catch every OperationCanceledException and call it harmless. A private timeout token may have fired while the request token is still active, which can mean a dependency failed its latency budget. The filter is what preserves the distinction.

Test the chain, not just the signature

A unit test that only verifies a method accepts CancellationToken proves very little. Use an already-canceled token to prove it reaches the operation, or hold a fake dependency open and cancel after the call starts:

[Fact]
public async Task BuildAsync_stops_when_the_caller_cancels()
{
    using var cancellation = new CancellationTokenSource();
    var task = service.BuildAsync(reportId, cancellation.Token);

    cancellation.Cancel();

    await Assert.ThrowsAnyAsync<OperationCanceledException>(() => task);
}

For database code, an integration test with the real provider is more honest than a mocked DbSet: cancellation support lives partly in the provider. The Microsoft documentation for RequestAborted explicitly calls out passing the signal to database queries and outgoing HTTP requests.

The habit is small: accept the token, pass the token, and decide where it stops being safe to obey it. It complements the other async mistakes that pass code review: code can be fully asynchronous and still waste every resource after its caller has left. Katabench Labs make these boundaries visible in running services; the Labs overview shows how the guided workspaces turn production patterns into executable checks.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.