Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 040 / 100

Pass CancellationToken everywhere in .NET & ASP.NET Core

Users close tabs. Your server should stop working too.

Expona Admin7 min read

Pass CancellationToken everywhere

When a user closes their browser tab, they're gone. The HTTP request dies, the connection closes, and your code is burning CPU cycles on work nobody needs anymore. Yet most applications ignore this signal entirely, plowing ahead with database queries, API calls, and heavy computations that should have stopped the moment the user left. The fix is straightforward: pass CancellationToken everywhere. It's free in ASP.NET Core, it's supported by every major library you use, and it transforms your application from wasteful to respectful of both user intent and server resources.

Free in endpoints — ASP.NET Core binds CancellationToken automatically

The beautiful part about ASP.NET Core is that CancellationToken is treated as a first-class citizen. When you declare a parameter of type CancellationToken in your endpoint handler, the framework automatically binds it to the request's HttpContext.RequestAborted token. You don't register anything, you don't wire anything up—it just works.

Here's the simplest example:

csharp
app.MapGet("/report", async (AppDb db, CancellationToken ct) =>
    await db.Orders.ToListAsync(ct));

When the user closes the tab or navigates away, the ct token is signaled as cancelled. If your ToListAsync hasn't finished yet, it will stop immediately and throw an OperationCanceledException. ASP.NET Core catches this exception and returns a 200 or closes the connection gracefully—the user sees nothing, but your server just freed up resources.

In a controller-based endpoint, it's equally simple:

csharp
public class ReportController : ControllerBase
{
    private readonly AppDb _db;

    public ReportController(AppDb db) => _db = db;

    [HttpGet("orders")]
    public async Task<IActionResult> GetOrders(CancellationToken cancellationToken)
    {
        var orders = await _db.Orders
            .AsNoTracking()
            .ToListAsync(cancellationToken);
        return Ok(orders);
    }
}

The parameter name doesn't matter—it can be ct, cancellationToken, or anything else. The framework detects the type and injects the request's cancellation token. This is where your cancellation journey begins, but it's also where many teams stop—and that's the mistake.

Pass it down every layer — every async call must receive the token

Receiving a CancellationToken at your endpoint is useless if you don't pass it to every async operation below. EF Core, HttpClient, Redis clients, message queues—they all accept and respect the token. A token that stops at the controller layer while your database queries and HTTP calls continue is worse than not using it at all, because you're pretending you're cancellation-aware when you're not.

Let me show you a realistic application layer. Say you're fetching order data, calling an external pricing API, and caching the result:

csharp
public class OrderService
{
    private readonly AppDb _db;
    private readonly HttpClient _httpClient;
    private readonly IDistributedCache _cache;

    public OrderService(AppDb db, HttpClient httpClient, IDistributedCache cache)
    {
        _db = db;
        _httpClient = httpClient;
        _cache = cache;
    }

    public async Task<Order> GetOrderWithPricingAsync(int orderId, CancellationToken ct)
    {
        // Check cache first
        var cached = await _cache.GetAsync($"order:{orderId}", ct);
        if (cached != null)
        {
            return JsonSerializer.Deserialize<Order>(cached);
        }

        // Fetch from database
        var order = await _db.Orders
            .Where(o => o.Id == orderId)
            .FirstOrDefaultAsync(ct);

        if (order == null)
            return null;

        // Call external API for pricing
        var pricingResponse = await _httpClient
            .GetAsync($"https://pricing-service/api/orders/{orderId}", ct);
        pricingResponse.EnsureSuccessStatusCode();

        var pricingJson = await pricingResponse.Content.ReadAsStringAsync(ct);
        var pricing = JsonSerializer.Deserialize<PricingInfo>(pricingJson);

        order.FinalPrice = pricing.AdjustedPrice;

        // Cache the result
        var serialized = JsonSerializer.SerializeToUtf8Bytes(order);
        await _cache.SetAsync($"order:{orderId}", serialized, new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
        }, ct);

        return order;
    }
}

Notice that ct is passed to every async operation: _cache.GetAsync, _db.Orders.FirstOrDefaultAsync, _httpClient.GetAsync, pricingResponse.Content.ReadAsStringAsync, and _cache.SetAsync. If the user closes the tab, any of these calls that haven't completed will be interrupted.

In your endpoint, you receive the token and pass it to the service:

csharp
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken ct)
{
    var order = await _orderService.GetOrderWithPricingAsync(id, ct);
    if (order == null)
        return NotFound();
    return Ok(order);
}

And in any background task spawned from that endpoint (a rare case, but it happens), you'd pass it there too:

csharp
// Bad: cancellation token ignored
_ = Task.Run(async () => await LogOrderAsync(orderId));

// Good: pass the token so cleanup can happen
_ = Task.Run(async () => await LogOrderAsync(orderId, ct), ct);

Timeouts with linked tokens — enforce per-operation time limits

Sometimes the request's cancellation token isn't enough. You might want to enforce a timeout on a specific operation, or have nested timeouts: the overall request has a 30-second limit, but calling an external API should never take more than 5 seconds.

This is where CancellationTokenSource.CreateLinkedTokenSource shines:

csharp
public async Task<PricingInfo> FetchPricingAsync(int orderId, CancellationToken ct)
{
    using var timeoutSource = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutSource.Token);

    try
    {
        var response = await _httpClient.GetAsync(
            $"https://pricing-service/api/orders/{orderId}",
            linkedSource.Token);
        return await response.Content.ReadAsAsync<PricingInfo>(linkedSource.Token);
    }
    catch (OperationCanceledException)
    {
        // Could be user cancellation or timeout—log accordingly
        if (timeoutSource.Token.IsCancellationRequested)
            _logger.LogWarning($"Pricing fetch timeout for order {orderId}");
        else
            _logger.LogInformation($"Pricing fetch cancelled for order {orderId}");
        throw;
    }
}

The linked token respects both the request cancellation and the 5-second timeout. Whichever happens first wins. This pattern scales well: you can have a 30-second request timeout, a 5-second external API timeout, and a 2-second cache timeout, all on the same request.

In ASP.NET Core 7+, you can also use the simpler CancelAfter method on the controller parameter directly, though it's less flexible:

csharp
public async Task<IActionResult> GetOrder(int id, CancellationToken ct)
{
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);

    var order = await _orderService.GetOrderWithPricingAsync(id, linkedCts.Token);
    return Ok(order);
}

Do not cancel writes halfway — preserve data consistency

There's one critical exception to "pass the token everywhere": critical write operations. Once you've started a database write—an INSERT, UPDATE, or DELETE that modifies core business data—cancelling halfway through creates data corruption and inconsistency.

The pattern is to validate first with the cancellation token, then switch to CancellationToken.None for the actual write:

csharp
public async Task<Order> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
    // Validation respects cancellation—if the user leaves, we stop early
    var customer = await _db.Customers
        .FirstOrDefaultAsync(c => c.Id == request.CustomerId, ct);

    if (customer == null)
        throw new NotFoundException("Customer not found");

    var items = await _db.Products
        .Where(p => request.ProductIds.Contains(p.Id))
        .ToListAsync(ct);

    if (items.Count != request.ProductIds.Count)
        throw new NotFoundException("One or more products not found");

    // Now write to the database without cancellation
    var order = new Order
    {
        CustomerId = request.CustomerId,
        CreatedAt = DateTime.UtcNow,
        Items = items,
        TotalAmount = items.Sum(p => p.Price)
    };

    _db.Orders.Add(order);
    await _db.SaveChangesAsync(CancellationToken.None);

    return order;
}

Why? Once validation passes and you're committing the order, stopping halfway leaves the database in a bad state. The order record exists but some items weren't attached, or the payment was recorded but the order inventory wasn't decremented. Use CancellationToken.None to say "this operation must complete."

The same applies to financial transactions, user account creation, and any multi-step operation where partial completion is worse than no completion.

Common mistakes

  • Forgetting to pass the token to async library calls. You accept the token at the endpoint but call db.Orders.ToListAsync() without it. The cancellation signal never reaches the database driver.
  • Using CancellationToken.None everywhere. Some teams disable cancellation for every operation "to be safe." This defeats the purpose entirely. Use None only for critical writes after validation.
  • Creating orphaned CancellationTokenSource objects. If you create a CancellationTokenSource and don't dispose it, you leak resources. Always use using statements.
  • Ignoring timeouts on external calls. If your external API is slow, passing the request's cancellation token alone isn't enough. Combine it with a timeout source via linked tokens.
  • Not logging cancellation context. When an operation is cancelled, log whether it's user cancellation or timeout. Helps tremendously with debugging production issues.

Key takeaways

  • ASP.NET Core automatically binds CancellationToken to request termination; declare it as an endpoint parameter and you're done.
  • Pass the token to every async operation below your endpoint: database queries, HTTP calls, cache operations, message publishing.
  • Use linked cancellation tokens to combine request cancellation with operation-specific timeouts for fine-grained control.
  • For critical writes after validation, use CancellationToken.None to ensure the operation completes and data stays consistent.
  • Log cancellation context so you can distinguish between user-initiated cancellation and timeout-triggered cancellation.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles