Back to blog
Backend Systemsintermediate

CancellationToken in .NET: The Parameter That Saves You Money

CancellationToken is the most ignored parameter in .NET — and ignoring it costs you CPU, database load, and cloud bills. This guide shows exactly where to pass it, common mistakes that silently waste resources, and patterns used in production systems.

LearnixoApril 19, 20268 min read
.NETC#AsyncPerformanceASP.NET CoreBest Practices
Share:𝕏

Every async method in .NET accepts a CancellationToken. Most developers accept it in their controller and pass it nowhere. This guide explains what that costs you — and how to fix it.


The Invisible Problem

When a user closes their browser tab, your HTTP connection drops. But your server doesn't know that unless you tell it to care.

Without CancellationToken:

User closes tab
    │
    ▼
Connection drops
    │
    ▼
API keeps running... reads DB... allocates memory... processes data
    │
    ▼
Response assembled — sent to nobody
    │
    ▼
CPU wasted. DB query wasted. Memory allocated and GC'd. Bill paid.

With CancellationToken:

User closes tab
    │
    ▼
ASP.NET Core sets HttpContext.RequestAborted = cancelled
    │
    ▼
CancellationToken propagates to service → repository → DB query
    │
    ▼
OperationCanceledException thrown — work stops immediately
    │
    ▼
No wasted resources. No wasted money.

The gap between these two diagrams is one parameter, passed consistently.


What CancellationToken Actually Is

A CancellationToken is a signal. It doesn't cancel anything by itself — it just tells you someone wants you to stop. You decide what to do with that signal.

C#
// Who creates the signal
var cts = new CancellationTokenSource();

// Who receives and checks the signal
CancellationToken token = cts.Token;

// Who fires the signal
cts.Cancel();      // cancel immediately
cts.CancelAfter(TimeSpan.FromSeconds(30)); // cancel after timeout

ASP.NET Core creates a CancellationTokenSource for every request. It fires when:

  • The client disconnects
  • The request times out
  • The server is shutting down

You access it via HttpContext.RequestAborted — or automatically, when you declare CancellationToken ct as a controller parameter.


The Controller Pattern

ASP.NET Core injects the request cancellation token automatically when you add it as a parameter:

C#
// ✅ Token injected automatically — no [FromQuery] or [FromBody] needed
[HttpGet("orders")]
public async Task<IActionResult> GetOrders(CancellationToken ct)
{
    var orders = await _orderService.GetOrdersAsync(ct);
    return Ok(orders);
}

This is step one. The problem is most codebases stop here.


Pass It All the Way Down

The token is useless if you accept it and drop it:

C#
// ❌ Accepted but dropped — DB query runs forever anyway
[HttpGet("orders")]
public async Task<IActionResult> GetOrders(CancellationToken ct)
{
    var orders = await _orderService.GetOrdersAsync(); // no ct
    return Ok(orders);
}

It must flow through every layer:

C#
// ✅ Controller
public async Task<IActionResult> GetOrders(CancellationToken ct)
    => Ok(await _orderService.GetOrdersAsync(ct));

// ✅ Service
public async Task<List<Order>> GetOrdersAsync(CancellationToken ct)
{
    ct.ThrowIfCancellationRequested(); // check before expensive work
    return await _repo.GetOrdersAsync(ct);
}

// ✅ Repository
public async Task<List<Order>> GetOrdersAsync(CancellationToken ct)
    => await _db.Orders
        .Where(o => o.IsActive)
        .ToListAsync(ct); // EF Core cancels the DB query

Where to Pass It

Entity Framework Core

EF Core passes the token to the underlying ADO.NET command. The database query is cancelled at the wire level — the SQL server stops executing.

C#
// All async EF methods accept CancellationToken
await _db.Orders.ToListAsync(ct);
await _db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
await _db.SaveChangesAsync(ct);
await _db.Orders.AnyAsync(o => o.UserId == userId, ct);
await _db.Orders.CountAsync(ct);

HttpClient

Cancels the outbound HTTP request. The TCP connection is torn down.

C#
var response = await _httpClient.GetAsync("/api/external", ct);
var result = await _httpClient.PostAsJsonAsync("/api/submit", payload, ct);

Stream I/O

C#
var buffer = new byte[4096];
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, ct);
await stream.WriteAsync(data, 0, data.Length, ct);
await fileStream.CopyToAsync(destination, ct);

Task.Delay

C#
// ❌ Waits the full duration even after cancellation
await Task.Delay(TimeSpan.FromMinutes(5));

// ✅ Cancelled immediately when token fires
await Task.Delay(TimeSpan.FromMinutes(5), ct);

CPU-Bound Loops

Network and DB calls cancel automatically when you pass the token. CPU-bound work doesn't — you have to check manually:

C#
public async Task ProcessLargeDatasetAsync(IEnumerable<Item> items, CancellationToken ct)
{
    foreach (var item in items)
    {
        ct.ThrowIfCancellationRequested(); // check each iteration

        // expensive CPU work
        var result = ComputeExpensiveResult(item);
        await SaveResultAsync(result, ct);
    }
}

For very tight loops, check every N iterations to reduce overhead:

C#
int i = 0;
foreach (var item in items)
{
    if (++i % 100 == 0)
        ct.ThrowIfCancellationRequested();

    Process(item);
}

Background Services

C#
public class OrderProcessingService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await ProcessNextBatchAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

stoppingToken fires when the application is shutting down. Pass it to every async call so the service drains gracefully instead of being killed mid-operation.


Handling OperationCanceledException

When a cancellation token fires and you've passed it correctly, OperationCanceledException is thrown. Do not swallow it.

C#
// ❌ Swallowing cancellation — hides the fact that work was cancelled
try
{
    await DoWorkAsync(ct);
}
catch (OperationCanceledException)
{
    // silently ignored — caller doesn't know work was cancelled
}

// ✅ Let it propagate — ASP.NET Core handles it correctly (returns 499)
[HttpGet("orders")]
public async Task<IActionResult> GetOrders(CancellationToken ct)
{
    var orders = await _orderService.GetOrdersAsync(ct); // throws if cancelled
    return Ok(orders);
}

ASP.NET Core converts unhandled OperationCanceledException to a 499 (client closed request) response. No 500 errors, no error logs, no noise.

If you need to clean up on cancellation:

C#
try
{
    await DoWorkAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    // cleanup — but re-throw so the caller knows
    await CleanupAsync(CancellationToken.None); // use None here — ct is already fired
    throw;
}

Note: use CancellationToken.None for cleanup operations — the token is already cancelled.


Linked Tokens

Combine multiple cancellation sources — for example, a request timeout AND client disconnect:

C#
[HttpGet("slow-report")]
public async Task<IActionResult> GetSlowReport(CancellationToken requestAborted)
{
    // Cancel if client disconnects OR after 10 seconds — whichever comes first
    using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(
        requestAborted,
        timeout.Token
    );

    var report = await _reportService.GenerateAsync(linked.Token);
    return Ok(report);
}

The Naming Convention That Helps

One convention that eliminates confusion: always name the parameter ct in private/internal methods, and cancellationToken in public API surfaces.

C#
// Public API — verbose name signals it's important
public async Task<Order> GetOrderAsync(int id, CancellationToken cancellationToken)
    => await GetOrderInternalAsync(id, cancellationToken);

// Internal — short name, fast to write
private async Task<Order> GetOrderInternalAsync(int id, CancellationToken ct)
    => await _db.Orders.FindAsync(new object[] { id }, ct);

More importantly: CancellationToken is always the last parameter. This is a .NET convention followed by the BCL, EF Core, HttpClient, and every Microsoft library.


Common Mistakes

1. Passing CancellationToken.None when a real token is available

C#
// ❌ Explicit opt-out — db query will never cancel
await _db.Orders.ToListAsync(CancellationToken.None);

// ✅
await _db.Orders.ToListAsync(ct);

2. Creating a new CancellationTokenSource instead of using the one you have

C#
// ❌ Creates a separate timeout that doesn't respond to request cancellation
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await DoWorkAsync(cts.Token);

// ✅ Link it so EITHER can cancel
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(TimeSpan.FromSeconds(30));
await DoWorkAsync(linked.Token);

3. Not propagating to parallel operations

C#
// ❌ Parallel tasks ignore cancellation
var tasks = items.Select(item => ProcessAsync(item));
await Task.WhenAll(tasks);

// ✅
var tasks = items.Select(item => ProcessAsync(item, ct));
await Task.WhenAll(tasks);

4. Forgetting ThrowIfCancellationRequested() before expensive synchronous work

C#
// ❌ Allocates a huge list even if the request was cancelled before we started
public async Task<Report> GenerateAsync(CancellationToken ct)
{
    var allData = await _db.Events.ToListAsync(ct); // cancelled here correctly
    var result = ExpensiveComputation(allData);      // but this runs even if ct fired at line above
    return result;
}

// ✅
public async Task<Report> GenerateAsync(CancellationToken ct)
{
    var allData = await _db.Events.ToListAsync(ct);
    ct.ThrowIfCancellationRequested(); // check before CPU-bound work
    var result = ExpensiveComputation(allData);
    return result;
}

The Rule That Sticks

Every async method has CancellationToken as its last parameter. Always. No exceptions. If a method doesn't support cancellation, it should be synchronous.

Apply this rule consistently and you get:

  • Free request cancellation propagation from controller to DB
  • Graceful background service shutdown
  • No wasted cloud compute on orphaned requests
  • Cleaner error logs (no false 500s from cancelled requests)

Quick Reference

C#
// ✅ Controller — auto-injected by ASP.NET Core
public async Task<IActionResult> Get(CancellationToken ct) { ... }

// ✅ EF Core — all async methods
await _db.Table.ToListAsync(ct);
await _db.SaveChangesAsync(ct);

// ✅ HttpClient
await _client.GetAsync(url, ct);

// ✅ Delay
await Task.Delay(ms, ct);

// ✅ Loops
ct.ThrowIfCancellationRequested();

// ✅ Streams
await stream.ReadAsync(buffer, ct);

// ✅ Background services — use stoppingToken from ExecuteAsync
while (!stoppingToken.IsCancellationRequested) { ... }

// ✅ Linked timeout + request cancellation
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(TimeSpan.FromSeconds(30));

A user closing their tab should immediately stop work on your server. If it doesn't — you're paying for it.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.