C# Cancellation Tokens

Background

I recently wrote a post on Streaming Massive Data with IAsyncEnumerable where I showed how streaming can dramatically improve responsiveness when working with huge datasets. One of the CSVs I used had over a million products (around 160MB), and that experiment really drove home how important it is to handle request lifecycles very carefully. Without proper cancellation, those long-running back-and-forth calls can consume resources unnecessarily and hurt scalability.

And that’s where cancellation tokens come in – giving us a way to stop wasted work, free up resources, and keep our ASP.NET Core apps lean even under heavy load.

Introducing C# Cancellation Tokens

Cancellation tokens and the concept of cooperative cancellation were introduced as part of the cancellation framework in .NET 4.

What are C# Cancellation Tokens?

A cancellation token is a way to stop asynchronous or long-running work. It’s built around two types in the System.Threading namespace:

  • CancellationTokenSource – the signaller (you own it, you can cancel it).
  • CancellationToken – the listener (passed down the chain, you observe it).

The runtime won’t automatically stop long-running work or memory-heavy operations if not using C# cancellation tokens. Cancellation in .NET is cooperative: you must create, pass, and check them regularly, and exit promptly when cancellation is requested. This applies whether the request comes from a user action, a timeout, an aborted HTTP request, or a host shutdown signal.

Using CancellationTokenSource & CancellationToken

Before we move on to look in depth at how cancellation tokens are used in asynchronous web applications, let’s look at a simple example of a token in action that uses the CancellationTokenSource and CancellationToken.

Create a simple Console App in Visual Studio, paste in the code, and click run to try it out:

using var cts = new CancellationTokenSource();
var token = cts.Token;

var task = Task.Run(async () =>
{
    while (true)
    {
        token.ThrowIfCancellationRequested();
        await Task.Delay(500, token);
        Console.WriteLine("Doing something important...");
    }
}, token);

cts.CancelAfter(TimeSpan.FromSeconds(10));

try
{
    await task;
}
catch (OperationCanceledException)
{
    Console.WriteLine("Task was cancelled.");
}

A console window will be displayed, similar to the one below:

Visual Studio Debug Console showing lines of "Doing something important..." followed by "Task was cancelled."

You’ll see that the task is being cancelled after a short period. Here’s what’s happening in the code, step-by-step:

  1. var token = cts.Token;
    • CancellationTokenSource owns the shared cancellation state. Remember to use using var cts because it implements IDisposable and should normally be disposed.
    • token is a struct that wraps a reference to that shared state. You can pass around copies of the struct and they all point back to the same cancellation state.
  2. Task.Run(…, token)
    • Passing token into Task.Run lets the task know that it should honour cancellation before starting and you must still observe the token inside the delegate.
  3. Inside the loop:
    • token.ThrowIfCancellationRequested() checks the shared state. If cts.Cancel() (or CancelAfter) has been called, it throws OperationCanceledException.
    • await Task.Delay(500, token) also monitors the token and will end early if cancellation is requested.

It looks like we’re passing token by value, but because each token struct refers to the same underlying cancellation state owned by cts, all consumers of it see the cancellation consistently. And this is really handy when working with multiple method calls or parallel tasks because one cancel signal is observed by all.

Why Cancellation Matters in ASP.NET Core

Web workloads can be unpredictable. A request might look healthy one second and disappear the next. A user might close their browser tab, or their device might lose power unexpectedly. The server is still pulling thousands of items from a database preparing to serve a request – but nobody’s listening anymore…

Infrastructure doesn’t help either. Proxies and gateways will happily drop a connection after 30 or 60 seconds if they don’t see a response in time. Your API code might still be querying the database or streaming data, but the work is already wasted. And that can come at a real cost if we’re buying compute power or operating in a shared environment where other apps and processes depend on those resources. This also helps reduce costs and scale more efficiently.

Cancellation tokens give you a single, consistent way to notice when the outside world has moved on – whether that’s a disconnected client, an impatient proxy, or a shutdown signal from a container orchestrator. And when you respond quickly, you can save compute, reduce noise, and make your service much more resilient.

Use HttpContext.RequestAborted

In ASP.NET Core, every incoming HTTP request has its own built-in CancellationToken, so you don’t need to create your own. It’s available in HttpContext.RequestAborted, which will trigger automatically if clients disconnect, browser tabs are closed, or the request times out.

Read more about RequestAborted on Microsoft Learn.

In Action: C# Cancellation Tokens

Let’s make the concept clearer by running through the demo project I created. Clone my C# Cancellation Tokens repo on GitHub if you want to see interactive examples of C# Cancellation Tokens in action and try them out yourself.

A screenshot shows code from the C# Cancellation Tokens In-Depth demo project.
A screenshot of Visual Studio showing C# Cancellation Tokens In-Depth demo project.

The code is intentionally simple and includes the console app we covered earlier, as well as a web app using different scenarios where you can see what happens when requests are terminated.

When you run the project, a browser will automatically launch the C# Cancellation Tokens demo. Each of the purple buttons on the page is clickable and triggers the named request, which can then either run to completion (if you let it) or can be cancelled using the red Cancel button. Each row is independent of the others and represents a different type of request.

A screenshot showing the Cancellation Token Demos webpage loaded in Chrome, with the CMD debug terminal on top. Notable outputs are highlighted in pink.
A screenshot of Chrome with the demo page for C# Cancellation Tokens loaded, alongside the debug terminal.

For easy reading, I’ve highlighted notable output in pink in the terminal window using the LoggerExtensions class, so pay close attention to what happens there as it’ll demonstrate exactly what’s going on with the request behind the scenes.

Slow Requests

First, let’s take a look at the difference between a slow request that ignores cancellation versus one that honours it properly.

/slow

// 1. /slow - Slow request that ignores cancellation.
app.MapGet("/slow", async () =>
{
    var sw = app.Logger.StartStat("/slow (no token)");
    await Task.Delay(5000); // intentionally ignores cancellation
    app.Logger.EndStat("/slow (no token)", sw);
    return Results.Ok("done");
});

This endpoint waits for five seconds no matter what. Once you begin the request by clicking the /slow button, it will run to completion until it’s finished, whether you click Cancel or not. The two pink blocks in the terminal output denote a request that ran to completion and one that was cancelled by me mid-way through executing:

Screenshot of demo webpage and terminal, showing that clicking the Cancel button has no effect on execution time.

The cancellation is not honoured and the requests take roughly the same time to complete.

/slow-cancel

This version is cancellation-aware and uses C# cancellation tokens. It takes the CancellationToken in the async method signature and passes it to Task.Delay. It also wraps it in a try/catch to handle OperationCanceledException. If you cancel, the task stops immediately and returns an HTTP 499 response.

// 2. Slow request that respects cancellation.
app.MapGet("/slow-cancel", async (CancellationToken token) =>
{
    var sw = app.Logger.StartStat("/slow-cancel");
    try
    {
        await Task.Delay(5000, token);
        app.Logger.EndStat("/slow-cancel (completed)", sw);
        return Results.Ok("done");
    }
    catch (OperationCanceledException) when (token.IsCancellationRequested)
    {
        app.Logger.CancelStat("/slow-cancel", sw);
        return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
    }
    catch (Exception ex)
    {
        app.Logger.FailStat("/slow-cancel", sw, ex);
        throw;
    }
});

HTTP 499 is a non-standard status code to indicate that the client closed the connection before the request was fulfilled. ASP.NET Core doesn’t set this automatically. You can return a more suitable one to meet your needs, if you wish.

Clicking /slow-cancel takes around five seconds to complete, but running it a second time and immediately clicking the Cancel button terminates its execution, as you can see by its much shorter completion time:

Screenshot of demo webpage and terminal, showing that clicking the Cancel button this time causes the asynchronous task to exit immediately.

Streaming with Cancellation

Streaming scenarios are where cancellation plays a big role. This /ticks endpoint writes a line of text to the response every second, until either the client cancels or disconnects. By checking token.ThrowIfCancellationRequested() inside the loop, we stop immediately when no one’s listening.

/ticks

// 3. Manual response streaming with cancellation.
app.MapGet("/ticks", async (HttpContext ctx, CancellationToken token) =>
{
    DemoHelpers.SetNoCache(ctx.Response);
    ctx.Response.ContentType = "text/plain";

    var sw = app.Logger.StartStat("/ticks (inline stream)");

    try
    {
        // Flush headers immediately so the client starts reading right away
        await ctx.Response.StartAsync(token);

        var i = 0;
        while (true)
        {
            token.ThrowIfCancellationRequested();
            await Task.Delay(1000, token);

            await ctx.Response.WriteAsync($"tick {i++}\n", token);
            await ctx.Response.Body.FlushAsync(token);
        }
    }
    catch (OperationCanceledException) when (token.IsCancellationRequested)
    {
        app.Logger.CancelStat("/ticks (client cancelled)", sw);
    }
    catch (Exception ex)
    {
        app.Logger.CancelStat($"/ticks (error: {ex.Message})", sw);
        throw; // let middleware handle unexpected errors
    }
});

Clicking the Start /ticks button begins streaming in real-time. After clicking Cancel, the terminal logs /ticks (client cancelled) immediately:

Screenshot of demo webpage and terminal, showing that clicking the Cancel button when streaming live data to the browser stops immediately when a CancellationToken in C# is used.

Streaming with cancellation is useful any time you want to drip-feed the client data but stop processing if the user goes away or explicitly cancels. It’s good for scenarios using server-sent events like live progress feeds or log tailing.

Check out my other post on Streaming Massive Data with IAsyncEnumerable for more on optimising your browser-client relationships.

Parallel Tasks

Cancellation also applies when running workloads in parallel. In the below example, we process 50 items using Parallel.ForEachAsync, each simulating work with a two second delay. Passing the token into the loop allows all parallel workers to stop immediately if cancellation is signalled:

/parallel

// 4. Parallel tasks with cancellation.
app.MapGet("/parallel", async (CancellationToken token) =>
{
    var sw = app.Logger.StartStat("/parallel");
    var ids = Enumerable.Range(1, 50);
    var bag = new ConcurrentBag<int>();

    try
    {
        await Parallel.ForEachAsync(ids, token, async (i, ct) =>
        {
            await Task.Delay(2000, ct);
            bag.Add(i);
        });

        app.Logger.EndStat("/parallel (completed)", sw);
        return Results.Json(new { processed = bag.Count });
    }
    catch (OperationCanceledException) when (token.IsCancellationRequested)
    {
        app.Logger.CancelStat("/parallel", sw);
        // The client won't see this as the connection is already gone, but it's useful for server-side logs/metrics.
        return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
    }
});

You can see in the terminal output how long /parallel takes to complete in the first run, with no cancellation, then with user cancellation terminating tasks and exiting (just over a second later):

Screenshot of demo webpage and terminal, showing a parallel run of tasks. A first requests shows a run to completion and the second with user cancellation, which exits immediately.

Parallel-with-cancellation is ideal when you fan out lots of independent units of work but want to stop wasting CPU, threads, and quotas the moment the request is no longer needed.

Composing Tasks

Sometimes it’s necessary to run multiple asynchronous operations in parallel and combine the results. This /compose endpoint kicks off two tasks (A and B) that return after 0.5s and 2s respectively. In this example, I’ve used Task.WhenAll to wait for both. If the client cancels while one is still pending, both tasks are aborted using C# cancellation tokens:

/compose

// 5. Response composition with cancellation.
app.MapGet("/compose", async (CancellationToken requestAborted) =>
{
    var sw = app.Logger.StartStat("/compose");

    try
    {
        // Two independent pieces of work that honour cancellation.
        var a = DemoHelpers.DelayReturn("A", 500, requestAborted);
        var b = DemoHelpers.DelayReturn("B", 2000, requestAborted);

        var result = await Task.WhenAll(a, b);

        app.Logger.EndStat("/compose (completed)", sw);
        return Results.Json(result);
    }
    catch (OperationCanceledException) when (requestAborted.IsCancellationRequested)
    {
        app.Logger.CancelStat("/compose (client cancelled)", sw);
        return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
    }
});

In the screenshot below, we can see that all tasks were aborted using C# cancellation tokens:

Screenshot of demo webpage and terminal, showing composed tasks. A first requests shows a run to completion and the second with user cancellation, which exits immediately. All tasks are cancelled.

This type of scenario might come into play when you have multiple APIs and want to bring together a bunch of data from different sources.

File Uploads

Lastly, let’s look at file uploads. This was a really interesting one to test because local uploads completed so quickly, it was really difficult to see the effect of cancellation in action. To combat this, I added the ThrottledCopyAsync method to the DemoHelpers class to slow things down a little.

This allows us to run this demo in the browser at a much slower pace, giving time to hit the Cancel button and signal to the method that the request is aborted. The helper method is used in the implementation of the endpoint below.

/upload

// 6. File upload (throttled) with cancellation; use file ≤ 1MB.
app.MapPost("/upload", async (HttpRequest req, CancellationToken token) =>
{
    var sw = app.Logger.StartStat("/upload (throttled)");

    // Throttle settings for demo
    const int bytesPerSecond = 64 * 1024; // ~64 KB/s
    const int minChunkSize = 8 * 1024;  // ≥8 KB chunks

    try
    {
        // Write to a unique temp file so repeated demos don't collide
        var tmpPath = Path.Combine(Path.GetTempPath(), $"upload-{Guid.NewGuid():N}.bin");
        await using var dest = File.Create(tmpPath);

        var copied = await DemoHelpers.ThrottledCopyAsync(req.Body, dest, bytesPerSecond, minChunkSize, token);

        app.Logger.EndStat("/upload (completed)", sw);
        return Results.Ok(new { size = copied, throttledTo = $"{bytesPerSecond} B/s", path = tmpPath });
    }
    catch (OperationCanceledException) when (token.IsCancellationRequested)
    {
        app.Logger.CancelStat("/upload (client cancelled)", sw);
        return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
    }
});

The throttling allows us to see what happens when we select the Cancel button:

Screenshot of demo webpage and terminal, showing the file upload task. A first requests shows a run to completion and the second with user cancellation, which exits immediately.

Cancellation tokens are extremely useful in file upload scenarios because they can be long-running, I/O heavy, and often get abandoned by users. Without cancellation, the server can keep reading and writing bytes to disk even if the user has exited or closed the tab – this wastes disk, CPU, and network bandwidth.

Controlled cancellation using C# cancellation tokens also protects against abandoned or partial files. With it, your application can clean up temp files there and then with a try-catch-finally. You might replace this block in the /upload endpoint to make it more robust where transfers have not completed:

string? tmpPath = null;
try
{
    tmpPath = Path.Combine(Path.GetTempPath(), $"upload-{Guid.NewGuid():N}.bin");
    await using var dest = File.Create(tmpPath);
    var copied = await DemoHelpers.ThrottledCopyAsync(req.Body, dest, bytesPerSecond, minChunkSize, token);
    app.Logger.EndStat("/upload (completed)", sw);
    return Results.Ok(new { size = copied, throttledTo = $"{bytesPerSecond} B/s", path = tmpPath });
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
    app.Logger.CancelStat("/upload (client cancelled)", sw);
    return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
}
finally
{
    if (tmpPath is not null && !token.IsCancellationRequested) { /* keep */ }
    else if (tmpPath is not null && System.IO.File.Exists(tmpPath))
    {
        try { System.IO.File.Delete(tmpPath); } catch { /* ... */ }
    }
}

Ultimately, cancellation during uploading isn’t just a nice-to-have – it can save money, resources, and even mitigate security risks in the long run.

Where to Use Cancellation Tokens

Thread the CancellationToken through these parts of your ASP.NET Core app. They’re the crucial spots where honouring cancellation makes a big impact.

API Endpoints & Controllers

Allow ASP.NET Core to bind the token automatically for incoming requests via your method signatures, then pass it down the chain so child objects know when they’re not needed anymore:

app.MapGet("/products", async (CancellationToken token) =>
{
    var results = await ChildMethod(token);
    ...
});

Outbound HTTP Requests

Always pass the token and consider using ResponseHeadersRead so the client can start processing immediately:

using var resp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
await using var stream = await resp.Content.ReadAsStreamAsync(token);

Data Access

Entity Framework Core and even plain old ADO.NET accept and observe cancellation tokens:

// EF
var items = await db.Items.Where(i => i.Active).ToListAsync(token);
await db.SaveChangesAsync(token);

// ADO.NET
await using var reader = await cmd.ExecuteReaderAsync(token);

Streaming Responses

Cancellation tokens are observed in manual streaming responses:

app.MapGet("/ticks", async (HttpContext ctx, CancellationToken token) =>
{
    ctx.Response.ContentType = "text/plain";
    await ctx.Response.StartAsync(token);

    var i = 0;
    while (true)
    {
        token.ThrowIfCancellationRequested();
        await ctx.Response.WriteAsync($"tick {i++}\n", token);
        await ctx.Response.Body.FlushAsync(token);
        await Task.Delay(1000, token);
    }
});

And IAsyncEnumerable:

app.MapGet("/events", (CancellationToken token) =>
    Results.Stream(async (stream, ct) =>
    {
        await foreach (var line in GetLinesAsync().WithCancellation(ct))
        {
            var bytes = Encoding.UTF8.GetBytes(line + "\n");
            await stream.WriteAsync(bytes, ct);
        }
    }, "text/plain"));

File Uploads & Downloads

Pass the token to stream operations so abandoned transfers stop immediately:

await using var dst = File.Create(tmpPath);
await req.Body.CopyToAsync(dst, token);
// or:
var read = await src.ReadAsync(buffer, token);
await dst.WriteAsync(buffer.AsMemory(0, read), token);

Other APIs

There are numerous other scenarios too – such as SignalR, gRPC, and even background work triggered by web services. The rule of thumb is if an async API offers a CancellationToken, pass it. If it doesn’t, wrap with await SomeAsync().WaitAsync(token) so the request can still be cancelled.

Note: WaitAsync doesn’t cancel the underlying work – use library-native cancellation or timeouts when available.

Don’t pass RequestAborted into work that outlives the request – such as queues. Use a longer-lived token (e.g., from IHostApplicationLifetime) for background work.

Final Thoughts on C# Cancellation Tokens

Managing and respecting cancellation in .NET apps isn’t optional – it’s how you keep services responsive, affordable, and resilient. The patterns here scale from simple demos to production: pass tokens through every async boundary, prefer APIs that accept CancellationToken, and exit fast on OperationCanceledException.

In web apps, treat HttpContext.RequestAborted as the source of truth for whether the client is still listening, and propagate it into downstream calls (DB, HTTP, queues). When a request is no longer needed, stop early, free resources, and keep capacity available for real work. Your users get snappier experiences, your costs go down, and your systems stay healthy under pressure.

In case you missed it, all the code used in this post is available in my C# Cancellation Tokens repo on GitHub.

Leave a Reply

Your email address will not be published. Required fields are marked *