//JorgenHoc
← All articles
Async C#By Jorge CalderónUpdated 18 min read

CancellationToken in C# — Practical Patterns

CancellationToken in C# with every claim asserted by a runnable sample: cooperative cancellation, timeout vs caller-cancel detection, linked tokens, Register semantics, and graceful shutdown in ASP.NET Core.

#csharp#async#dotnet

Unresponsive operations that can't be interrupted are a common source of resource leaks, poor user experience, and cascading failures in distributed systems. CancellationToken is .NET's cooperative cancellation primitive — it doesn't kill threads, it signals intent, and your code decides how to react.

What CancellationToken Solves

Before cancellation tokens, stopping an async operation required shared bool flags, custom events, or thread-abort (which is broken by design). The problem: callers had no standard way to tell an operation "stop what you're doing."

CancellationToken establishes a contract:

  • Producer (CancellationTokenSource) decides when to cancel
  • Consumer (your async method) checks the token and reacts appropriately
  • Framework (ASP.NET Core, EF Core, HttpClient) does this automatically when you pass the token through
// Without cancellation — the caller has no way to stop this; the only backstop
// is HttpClient's global 100-second default Timeout
public async Task<string> FetchDataAsync(string url)
{
    var response = await _httpClient.GetAsync(url);
    return await response.Content.ReadAsStringAsync();
}
 
// With cancellation — respects caller's intent
public async Task<string> FetchDataAsync(string url, CancellationToken cancellationToken)
{
    var response = await _httpClient.GetAsync(url, cancellationToken);
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

The key insight: cancellation is cooperative. The token cannot force your code to stop. You must observe it.

Every Claim Here Is Asserted, Not Narrated

Cooperative cancellation has a useful property: every claim about it is checkable as a hard fact — did the loop stop, what state did the task end in, which exception type came out, whose token is on it. samples/cancellationtoken-csharp turns this article's claims into assertions, with no timing races: every token is cancelled before the work it should stop, or the work waits infinitely so only cancellation can end it.

DemoAsserted
Cooperativea loop that never reads the token processes 5/5 items after Cancel(); the observing version processes 0/5 and its task ends Canceled, not Faulted
Graceful draina while (!token.IsCancellationRequested) loop falls through — the caller sees RanToCompletion, no exception
Linked tokensthe when (callerToken.IsCancellationRequested) filter separates "my timeout" from "caller cancelled" in both directions
Registerthe callback fires exactly once across two Cancel() calls; a disposed registration never fires; registering on an already-cancelled token runs the callback synchronously
Task.Runwith a pre-cancelled token the delegate never executes; await throws TaskCanceledException carrying the caller's token
HttpClientTimeoutTaskCanceledException with inner TimeoutException; a caller token → no inner TimeoutException, and the exception carries the caller's token
DisposalCancel() after Dispose() throws ObjectDisposedException
Console output of the sample: a loop ignoring a cancelled token processes 5 of 5 items while the observing version processes 0 and ends Canceled; a bool-check drain loop ends RanToCompletion; linked tokens classify timeout versus caller cancellation in both branches; Register fires exactly once, a disposed registration never fires, and registering on an already-cancelled token runs synchronously; Task.Run with a pre-cancelled token never runs the delegate; HttpClient.Timeout produces TaskCanceledException with inner TimeoutException while a caller token does not; and Cancel after Dispose throws ObjectDisposedException. All checks passed.
The sample run: 16 assertions, ending with the one-line summary — the token never stops anything; code that observes it does.

The HttpClient demo deserves one note: to prove timeout behaviour without network flakiness, the sample starts a TcpListener on a loopback port that accepts the connection and then never responds — a deterministic way to make an HTTP request hang.

CancellationTokenSource — Creating Tokens

CancellationTokenSource (CTS) is the controller. It creates the token and holds the ability to cancel it.

// Basic manual cancellation
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
 
// Pass token to your work
var workTask = DoWorkAsync(token);
 
// Cancel from another thread / user action
cts.Cancel(); // signals cancellation; registered callbacks run on THIS thread, now
// cts.CancelAsync(); // .NET 8+ — returns a Task instead of running callbacks inline
 
await workTask; // throws OperationCanceledException

Lifetime and Disposal

// CancellationTokenSource implements IDisposable
// Always dispose when you own the source
using var cts = new CancellationTokenSource();
 
// Or in a try/finally if not using 'using'
var cts = new CancellationTokenSource();
try
{
    await DoWorkAsync(cts.Token);
}
finally
{
    cts.Dispose(); // releases internal WaitHandle if allocated
}

A correction from an earlier version of this article: it showed await using var cts = new CancellationTokenSource() with a claim that CTS gained IAsyncDisposable in .NET 6. It did not — that line does not compile, which I found out the direct way when porting the article's snippets into the sample project. CancellationTokenSource is plain IDisposable; use using.

⚠️

Calling Cancel() after Dispose() throws ObjectDisposedException — the sample's demo 7 asserts exactly this. If multiple components share a CTS, coordinate ownership carefully.

Passing Tokens Through the Call Chain

This is the single most important practice. Every async method that does I/O, waits, or loops should accept and forward a CancellationToken.

// Repository layer
public async Task<Order> GetOrderAsync(int orderId, CancellationToken cancellationToken = default)
{
    return await _dbContext.Orders
        .Include(o => o.Items)
        .FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
}
 
// Service layer — passes token down
public async Task<OrderDto> ProcessOrderAsync(int orderId, CancellationToken cancellationToken = default)
{
    var order = await _orderRepo.GetOrderAsync(orderId, cancellationToken);
    
    // Check before expensive operation
    cancellationToken.ThrowIfCancellationRequested();
    
    var enriched = await _enrichmentService.EnrichAsync(order, cancellationToken);
    return _mapper.Map<OrderDto>(enriched);
}
 
// ASP.NET Core controller — framework passes cancellationToken automatically
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken cancellationToken)
{
    var order = await _orderService.ProcessOrderAsync(id, cancellationToken);
    return order is null ? NotFound() : Ok(order);
}

The = default default parameter allows callers that don't care about cancellation to omit it — CancellationToken.None is the default, which never fires.

The = default Convention

// These are equivalent — CancellationToken.None never fires
await DoWorkAsync();
await DoWorkAsync(CancellationToken.None);
await DoWorkAsync(default);
await DoWorkAsync(default(CancellationToken));

Timeout Tokens — CancelAfter

The most common use case: cancel an operation if it takes too long.

// CancelAfter arms a timer on the existing source (available since .NET Framework 4.5)
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(30));
 
try
{
    var result = await FetchDataAsync(cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
    // Distinguishes timeout from user cancellation
    throw new TimeoutException("Operation exceeded 30s");
}
// Constructor overload — sets timeout at creation
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
 
try
{
    await ProcessAsync(cts.Token);
}
catch (OperationCanceledException)
{
    _logger.LogWarning("Operation timed out after 30 seconds");
    throw;
}

Adding a Timeout to an Existing Token

// CreateLinkedTokenSource + CancelAfter: cancels when the upstream token fires
// OR the timeout elapses, whichever comes first
using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));

(An earlier version of this article called this a ".NET 8 TimeoutToken" — there is no such API. Both pieces here are old: CreateLinkedTokenSource shipped with the TPL in .NET Framework 4, CancelAfter in 4.5.)

💡

Prefer CancelAfter over creating a new CTS with a timeout in the constructor when you already have a CTS — it reuses the existing instance instead of allocating a new one.

IsCancellationRequested vs ThrowIfCancellationRequested

Two ways to check for cancellation — each has its place.

public async Task ProcessItemsAsync(IEnumerable<Item> items, CancellationToken cancellationToken)
{
    foreach (var item in items)
    {
        // Option 1: ThrowIfCancellationRequested — throws OperationCanceledException
        // Use inside loops or between stages where you want to abort immediately
        cancellationToken.ThrowIfCancellationRequested();
        
        await ProcessItemAsync(item, cancellationToken);
    }
}
public async Task ProcessWithCleanupAsync(CancellationToken cancellationToken)
{
    while (!cancellationToken.IsCancellationRequested)
    {
        // Option 2: IsCancellationRequested — bool check, no exception
        // Use when you want to do cleanup before returning
        var batch = await ReadBatchAsync(cancellationToken);
        
        if (batch.Count == 0)
            break;
            
        await ProcessBatchAsync(batch, cancellationToken);
    }
    
    // Falls through naturally — caller sees completed task, not canceled
    // Appropriate for graceful shutdown scenarios
}

The difference between the two is visible in Task.Status, and the sample asserts both sides: the ThrowIfCancellationRequested version ends in Canceled (the async state machine special-cases OperationCanceledException — the task is not Faulted), while the bool-check drain ends in RanToCompletion as if nothing happened. Pick based on which of those two stories you want the caller to see.

When to Use Which

ScenarioRecommended
Loop body between iterationsThrowIfCancellationRequested()
Worker loop condition!IsCancellationRequested
Before expensive CPU workThrowIfCancellationRequested()
Graceful drain / cleanupIsCancellationRequested
Passing to awaitable APIsPass token directly
After awaitable completesThrowIfCancellationRequested() optional
// Practical: check before starting expensive work, pass through awaits
public async Task<byte[]> CompressAndUploadAsync(
    Stream data,
    CancellationToken cancellationToken)
{
    // Check before CPU-intensive work
    cancellationToken.ThrowIfCancellationRequested();
    
    var compressed = await CompressAsync(data, cancellationToken);
    
    // No need to check again — CompressAsync already did it internally
    // But if there's a gap between awaits with no internal checking:
    cancellationToken.ThrowIfCancellationRequested();
    
    return await _storage.UploadAsync(compressed, cancellationToken);
}

Linked Tokens — CreateLinkedTokenSource

Real systems often have multiple cancellation sources: request timeout, user abort, server shutdown. CreateLinkedTokenSource combines them into a single token.

public async Task<SearchResult> SearchAsync(
    string query,
    CancellationToken requestCancellationToken) // from HTTP request
{
    // Impose an additional per-operation timeout
    // The linked token fires if EITHER source fires first
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        requestCancellationToken,
        timeoutCts.Token);
 
    try
    {
        return await _searchEngine.SearchAsync(query, linkedCts.Token);
    }
    catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
    {
        // Request wasn't cancelled — must be our timeout
        throw new TimeoutException($"Search exceeded 5s for query: {query}");
    }
    // If requestCancellationToken fired, OperationCanceledException propagates naturally
}

This classification is easy to get subtly backwards, so the sample's demo 3 asserts both branches: with a clean caller token and a fired timeout it classifies "timeout", and with a cancelled caller it classifies "caller" — same catch filters as above.

Multiple Upstream Sources

// Combine three sources: request + user abort + global shutdown
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
    httpContext.RequestAborted,        // HTTP request cancelled
    userCancellationToken,             // explicit user action
    _appLifetime.ApplicationStopping); // server shutting down
 
await DoLongOperationAsync(linkedCts.Token);
⚠️

CreateLinkedTokenSource allocates a new CancellationTokenSource. Always dispose it, especially in high-throughput paths like hot API endpoints.

Register() — Cleanup Callbacks

Register() attaches a callback that fires when cancellation is requested. Useful for canceling non-cancellable operations or releasing resources.

public async Task<string> PollWithCallbackAsync(
    Func<Task<string?>> pollFunc,
    CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource<string>(
        TaskCreationOptions.RunContinuationsAsynchronously);
 
    // Register cleanup: cancel the TaskCompletionSource when token fires
    using var registration = cancellationToken.Register(() =>
    {
        tcs.TrySetCanceled(cancellationToken);
    });
 
    // Start polling in background
    _ = Task.Run(async () =>
    {
        while (!tcs.Task.IsCompleted)
        {
            var result = await pollFunc();
            if (result is not null)
            {
                tcs.TrySetResult(result);
                return;
            }
            await Task.Delay(500); // poll interval
        }
    });
 
    return await tcs.Task;
}
// Bridging legacy callback-based API to cancellation
public Task WaitForEventAsync(LegacyEventSource source, CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
    void OnEvent(object? sender, EventArgs e) => tcs.TrySetResult();
 
    source.EventOccurred += OnEvent;
 
    // Clean up subscription if cancelled
    cancellationToken.Register(() =>
    {
        source.EventOccurred -= OnEvent;
        tcs.TrySetCanceled(cancellationToken);
    });
 
    return tcs.Task;
}

Register() Returns a Disposable

// The CancellationTokenRegistration should be disposed when no longer needed
// to avoid holding callbacks alive longer than necessary
using var registration = cancellationToken.Register(() => DoCleanup());
 
// Without 'using', the callback lives until the SOURCE is disposed
// This is usually fine for short-lived operations, but can leak for long-lived ones

Three Register behaviours worth knowing cold, all asserted by the sample's demo 4: the callback fires exactly once even if Cancel() is called twice; a disposed registration never fires; and registering on an already-cancelled token runs the callback synchronously, on your current thread, inside the Register call itself — which means a callback that takes a lock can deadlock right there.

Graceful Shutdown in ASP.NET Core

ASP.NET Core exposes application lifetime events through IHostApplicationLifetime. These are pre-wired CancellationToken instances.

// Program.cs — configure shutdown timeout
builder.Services.Configure<HostOptions>(options =>
{
    // Give background services 30s to finish before force-killing
    options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});
// Background service — proper graceful shutdown
public class OrderProcessingService : BackgroundService
{
    private readonly ILogger<OrderProcessingService> _logger;
    private readonly IOrderQueue _queue;
 
    public OrderProcessingService(ILogger<OrderProcessingService> logger, IOrderQueue queue)
    {
        _logger = logger;
        _queue = queue;
    }
 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Order processing started");
 
        // stoppingToken is provided by the host — fires on SIGTERM/Ctrl+C
        await foreach (var order in _queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                await ProcessOrderAsync(order, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                // Don't log as error — this is expected on shutdown
                _logger.LogInformation("Shutdown requested, stopping order processing");
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to process order {OrderId}", order.Id);
                // Continue processing next order
            }
        }
 
        _logger.LogInformation("Order processing stopped");
    }
 
    private async Task ProcessOrderAsync(Order order, CancellationToken cancellationToken)
    {
        // All downstream calls receive the stoppingToken
        await _orderService.ValidateAsync(order, cancellationToken);
        await _orderService.FulfillAsync(order, cancellationToken);
        await _notificationService.SendConfirmationAsync(order, cancellationToken);
    }
}

IHostApplicationLifetime for Non-BackgroundService Code

public class DataSyncService
{
    private readonly IHostApplicationLifetime _lifetime;
    
    public DataSyncService(IHostApplicationLifetime lifetime)
    {
        _lifetime = lifetime;
    }
 
    public async Task SyncAsync(CancellationToken userCancellationToken)
    {
        // Combine user request cancellation with application shutdown
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            userCancellationToken,
            _lifetime.ApplicationStopping);
 
        await PerformSyncAsync(linkedCts.Token);
    }
}

Request Cancellation in Controllers

// ASP.NET Core automatically binds CancellationToken to HttpContext.RequestAborted
[ApiController]
[Route("api/[controller]")]
public class ReportsController : ControllerBase
{
    [HttpGet("{id}/generate")]
    public async Task<IActionResult> GenerateReport(
        int id,
        CancellationToken cancellationToken) // auto-bound from HttpContext.RequestAborted
    {
        try
        {
            var report = await _reportService.GenerateAsync(id, cancellationToken);
            return Ok(report);
        }
        catch (OperationCanceledException)
        {
            // Client disconnected — nothing will ever read this response.
            // 499 is an nginx convention, NOT something ASP.NET Core produces or
            // handles for you; returning it here only labels the request in your
            // own logs and metrics instead of a misleading 200 or 500.
            return StatusCode(499);
        }
    }
}

Cancelling HttpClient Requests

HttpClient accepts CancellationToken in all request methods. Pass it every time.

public class WeatherService
{
    private readonly HttpClient _httpClient;
 
    public WeatherService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }
 
    public async Task<WeatherData> GetWeatherAsync(
        string city,
        CancellationToken cancellationToken)
    {
        // Cancel if caller cancels OR if request takes > 10s
        using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cancellationToken,
            timeoutCts.Token);
 
        try
        {
            var response = await _httpClient.GetAsync(
                $"/weather/{city}",
                linkedCts.Token);
 
            response.EnsureSuccessStatusCode();
 
            return await response.Content.ReadFromJsonAsync<WeatherData>(linkedCts.Token)
                ?? throw new InvalidOperationException("Empty response");
        }
        catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
        {
            throw new TimeoutException($"Weather request for {city} timed out");
        }
    }
}

HttpClient Global Timeout vs Per-Request Cancellation

// HttpClient.Timeout — applies to ALL requests through that client instance
// CancellationToken — per-request
// BOTH surface as TaskCanceledException (which derives from OperationCanceledException).
// Since .NET 5 the timeout carries an inner TimeoutException — that is the check
// Microsoft added precisely because the two cases were indistinguishable before:
 
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
    // HttpClient.Timeout fired
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
    // Caller cancelled
}

The sample's demo 6 asserts both branches against a local server that accepts the connection and never responds: the Timeout case has the inner TimeoutException, the caller-token case does not — and in the caller case ex.CancellationToken equals the caller's token (I verified this on .NET 10; it also holds back to .NET 5, where the token started being propagated onto the exception).

EF Core Query Cancellation

Entity Framework Core passes CancellationToken to the database driver. The query gets cancelled at the database level — no wasted DB resources.

public class ProductRepository
{
    private readonly AppDbContext _context;
 
    public ProductRepository(AppDbContext context)
    {
        _context = context;
    }
 
    // Pass token to all async EF Core methods
    public async Task<List<Product>> GetActiveProductsAsync(
        string category,
        CancellationToken cancellationToken = default)
    {
        return await _context.Products
            .Where(p => p.Category == category && p.IsActive)
            .OrderBy(p => p.Name)
            .AsNoTracking()
            .ToListAsync(cancellationToken); // cancels the DB query
    }
 
    public async Task<int> BulkUpdatePricesAsync(
        string category,
        decimal multiplier,
        CancellationToken cancellationToken = default)
    {
        // ExecuteUpdateAsync cancels if token fires mid-operation
        return await _context.Products
            .Where(p => p.Category == category)
            .ExecuteUpdateAsync(
                setters => setters.SetProperty(p => p.Price, p => p.Price * multiplier),
                cancellationToken);
    }
 
    public async Task<Product?> FindWithRetryAsync(
        int id,
        CancellationToken cancellationToken = default)
    {
        for (int attempt = 0; attempt < 3; attempt++)
        {
            try
            {
                return await _context.Products.FindAsync(
                    new object[] { id },
                    cancellationToken);
            }
            catch (OperationCanceledException)
            {
                throw; // never swallow cancellation
            }
            catch (Exception ex) when (attempt < 2)
            {
                _logger.LogWarning(ex, "Attempt {Attempt} failed, retrying", attempt + 1);
                await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
            }
        }
 
        return null;
    }
}

Combining Operation Timeout + Request Cancellation

A complete pattern for production use: per-operation timeout combined with the upstream request's cancellation.

public class ProductSearchService
{
    private readonly ISearchIndex _searchIndex;
    private readonly ILogger<ProductSearchService> _logger;
 
    // Configurable timeout per operation type
    private static readonly TimeSpan SearchTimeout = TimeSpan.FromSeconds(3);
    private static readonly TimeSpan SuggestTimeout = TimeSpan.FromMilliseconds(500);
 
    public ProductSearchService(ISearchIndex searchIndex, ILogger<ProductSearchService> logger)
    {
        _searchIndex = searchIndex;
        _logger = logger;
    }
 
    public async Task<SearchResult> SearchAsync(
        SearchRequest request,
        CancellationToken requestCancellationToken = default)
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestCancellationToken);
        cts.CancelAfter(SearchTimeout);
 
        try
        {
            return await _searchIndex.SearchAsync(request, cts.Token);
        }
        catch (OperationCanceledException) when (requestCancellationToken.IsCancellationRequested)
        {
            _logger.LogInformation("Search cancelled by request for query: {Query}", request.Query);
            throw; // re-throw as-is, propagate upstream
        }
        catch (OperationCanceledException)
        {
            // Our timeout fired, not the request cancellation
            _logger.LogWarning("Search timed out after {Timeout}s for query: {Query}",
                SearchTimeout.TotalSeconds, request.Query);
            throw new SearchTimeoutException(request.Query, SearchTimeout);
        }
    }
 
    public async Task<IReadOnlyList<string>> GetSuggestionsAsync(
        string prefix,
        CancellationToken requestCancellationToken = default)
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestCancellationToken);
        cts.CancelAfter(SuggestTimeout);
 
        try
        {
            return await _searchIndex.GetSuggestionsAsync(prefix, cts.Token);
        }
        catch (OperationCanceledException)
        {
            // Suggestions are non-critical — return empty on timeout or cancellation
            _logger.LogDebug("Suggestions timed out or cancelled for prefix: {Prefix}", prefix);
            return Array.Empty<string>();
        }
    }
}

Common Mistakes

Never Swallow OperationCanceledException

// WRONG — hides cancellation, caller thinks work completed normally
try
{
    await DoWorkAsync(cancellationToken);
}
catch (OperationCanceledException)
{
    // Swallowed! Caller has no idea cancellation happened
}
 
// CORRECT — log if needed, but always re-throw
catch (OperationCanceledException ex)
{
    _logger.LogInformation("Work was cancelled");
    throw; // preserve original exception and stack trace
}

Don't Catch and Wrap Unnecessarily

// WRONG — loses cancellation semantics
catch (OperationCanceledException ex)
{
    throw new ApplicationException("Operation failed", ex); // bad!
}
 
// CORRECT — only wrap if you're adding real context, and use a type
// that still communicates cancellation or use the original exception
catch (OperationCanceledException)
{
    throw; // or let it propagate naturally
}

Avoid Task.Run Without Forwarding the Token

// WRONG — fire-and-forget ignores cancellation
var task = Task.Run(() => HeavyComputation()); // no token!
 
// CORRECT — token passed to Task.Run AND the work
var task = Task.Run(() => HeavyComputation(cancellationToken), cancellationToken);
// The outer token cancels task scheduling; the inner one cancels the work itself

The outer token's effect is observable: the sample's demo 5 passes an already-cancelled token to Task.Run and asserts the delegate never executes — the task goes straight to Canceled and await throws TaskCanceledException carrying that token.

Don't Use CancellationToken for Flow Control

// WRONG — CancellationToken is for cancellation, not branching
if (cancellationToken.IsCancellationRequested)
{
    return GetCachedResult(); // using cancellation as "fast path"!
}
 
// CORRECT — check cancellation, then throw or return cleanly
cancellationToken.ThrowIfCancellationRequested();
return await FetchFreshResult(cancellationToken);

Decision Reference

Which Pattern to Use

SituationPattern
Simple timeout on an operationnew CancellationTokenSource(timeout)
Add timeout to existing tokenCreateLinkedTokenSource + CancelAfter
HTTP request cancelled by clientHttpContext.RequestAborted passed through
Background service shutdownBackgroundService.stoppingToken
Multiple cancellation sourcesCreateLinkedTokenSource(token1, token2, ...)
Cleanup on cancellationcancellationToken.Register(callback)
Check in tight loopThrowIfCancellationRequested()
Graceful drain without exceptionIsCancellationRequested as loop condition

Exception Handling Decision

ScenarioAction
Cancellation is expected (client disconnect)Catch, log debug/info, return 499 or empty
Timeout is exceptional for this operationCatch, wrap in domain exception, log warning
Background worker cancelled on shutdownCatch, log info, exit loop cleanly
Propagating through middleware/pipelineRe-throw (throw;) without wrapping
Retry loopCatch other exceptions, re-throw cancellation

Summary

CancellationToken works well when you treat it consistently: accept it in every async method, forward it to every awaitable call, check between stages with ThrowIfCancellationRequested, and never swallow OperationCanceledException. The linked token pattern handles the real-world need to combine multiple cancellation sources — request lifetime, per-operation timeout, and application shutdown — into a single token that any downstream library can use without knowing the origin.

The difference between a resilient service and one that leaks resources under load often comes down to whether cancellation is wired end-to-end.

Every behavioural claim above is asserted by samples/cancellationtoken-csharp — clone it and run dotnet run if you want to see any of them fail on a future runtime.

Cancellation overlaps with two neighbouring topics worth reading next. OperationCanceledException is an exception like any other, so the rules in async exception handling — particularly around Task.WhenAll and fire-and-forget work — apply directly to cancelled operations. And if you are streaming results rather than returning a list, IAsyncEnumerable<T> has its own cancellation mechanism via [EnumeratorCancellation] that does not behave quite like a plain async method.

Further reading

About the author

Jorge Calderón

Software engineer with over a decade building and operating .NET applications in production — EF Core data layers, async-heavy services, and Azure and container deployments. Every benchmark and sample project in these guides is published in a public GitHub repository so you can rerun it yourself.

GitHub profileLinkedIn ↗Benchmarks & sample code

Related articles