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
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.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.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.