CancellationToken — Cooperative Cancellation and Timeouts

Long-running async operations should support cancellation — letting the caller abort them cleanly rather than waiting for them to complete or killing the process. C#’s cancellation model is cooperative: the caller signals cancellation via a CancellationToken, and the callee checks for cancellation at appropriate points and responds gracefully. In ASP.NET Core, every request has a HttpContext.RequestAborted token that fires when the client disconnects. Propagating this token through all async operations avoids wasting CPU and I/O on requests the client no longer needs.

CancellationToken Basics

// ── CancellationTokenSource — creates and signals cancellation ─────────────
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

// Cancel after a timeout
using var ctsWith Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));

// ── Passing tokens through the call chain ─────────────────────────────────
async Task ProcessAsync(CancellationToken ct = default)
{
    // Option 1: Pass to awaited operations
    var data = await FetchDataAsync(ct);

    // Option 2: Check manually
    ct.ThrowIfCancellationRequested();   // throws OperationCanceledException if cancelled

    // Option 3: Check without throwing
    if (ct.IsCancellationRequested)
    {
        Console.WriteLine("Cancellation requested — aborting.");
        return;
    }

    await SaveAsync(data, ct);
}

// ── Triggering cancellation ────────────────────────────────────────────────
cts.Cancel();   // signals cancellation — all tokens from this source are cancelled
// OR:
cts.CancelAfter(TimeSpan.FromSeconds(30));   // cancel automatically after 30s
Note: When cancellation is requested and a method calls ct.ThrowIfCancellationRequested(), it throws OperationCanceledException — a special exception that .NET and ASP.NET Core treat differently from other exceptions. ASP.NET Core recognises OperationCanceledException as a normal cancellation (not a server error) and does not log it as an error or return 500 — it returns 499 (client closed request) or simply closes the connection. Always let this exception propagate without catching it, unless you need to do specific cleanup.
Tip: Use CancellationTokenSource.CreateLinkedTokenSource(token1, token2) to create a token that is cancelled when either source is cancelled. In ASP.NET Core, combine the request’s HttpContext.RequestAborted with your own timeout token: using var linked = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted, timeoutCts.Token). Now the operation cancels if the client disconnects OR if your timeout fires — whichever comes first.
Warning: After calling cts.Cancel(), do not continue using the CancellationTokenSource for new operations. Once a source is cancelled, all tokens from it are permanently cancelled — you cannot “un-cancel” them. If you need to retry after cancellation, create a new CancellationTokenSource. Also, always Dispose() CancellationTokenSource objects (use using) — they hold an internal timer if created with a timeout, which leaks if not disposed.

ASP.NET Core — RequestAborted

// ── Controller — pass request cancellation token to all operations ─────────
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(
    int id,
    CancellationToken cancellationToken)   // ASP.NET Core injects RequestAborted automatically
{
    var post = await _service.GetByIdAsync(id, cancellationToken);
    return Ok(post);
}

// ── Service — propagate the token ─────────────────────────────────────────
public async Task<Post> GetByIdAsync(int id, CancellationToken ct = default)
{
    var post = await _repo.GetByIdAsync(id, ct);  // cancelled if client disconnects
    if (post is null) throw new NotFoundException(nameof(Post), id);

    // Run potentially expensive enrichment — check for cancellation first
    ct.ThrowIfCancellationRequested();
    await _enrichmentService.EnrichAsync(post, ct);

    return post;
}

// ── Registration callback — run custom cleanup on cancellation ─────────────
ct.Register(() => Console.WriteLine("Operation was cancelled — cleaning up."));

// ── Handling cancellation gracefully ──────────────────────────────────────
try
{
    await LongRunningOperationAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    // Expected cancellation — not an error
    _logger.LogInformation("Operation cancelled by request.");
    return StatusCode(499);   // or just return without a response
}

Common Mistakes

Mistake 1 — Not passing CancellationToken to EF Core operations

❌ Wrong — database query continues even after client disconnects:

var post = await _db.Posts.FindAsync(id);   // no cancellation support

✅ Correct — all EF Core async methods accept a CancellationToken:

var post = await _db.Posts.FindAsync(new object[] { id }, ct);

Mistake 2 — Catching OperationCanceledException and treating it as an error

❌ Wrong — logs an error and returns 500 for a normal client disconnect:

catch (OperationCanceledException ex)
{
    _logger.LogError(ex, "Error!");   // not an error — normal cancellation
    return StatusCode(500);
}

✅ Correct — let it propagate or handle explicitly as a non-error condition.

🧠 Test Yourself

An API request starts a 5-second database query. The client disconnects after 2 seconds. Without a CancellationToken, what happens? With one?