CancellationToken in ASP.NET Core – A Complete Guide

Waleed NaveedWaleed Naveed
8 min read

Cancellation token in .NET is one of those concepts that engineers often overlook, but in production-grade applications, timeouts, client disconnects, cooperative cancellation, and resource cleanup are absolutely essential.

In this blog, we’ll build an ASP.​NET Core demo project that demonstrates real-world scenarios where CancellationToken plays a crucial role:

  • Client-initiated cancellation

  • Timeout with CancelAfter

  • Linked tokens (timeout + client abort)

  • Parallel tasks with cooperative cancellation

  • Cleanup on cancel (file write)

  • Background Worker with queue and graceful cancellation

Let’s discuss each scenario in detail with code.

Client-Initiated Cancellation via HttpContext.RequestAborted

ASP.​NET Core automatically provides HttpContext.RequestAborted, which is triggered when the client disconnects or cancels the request.

[HttpGet("reports/generate")]
public async Task<IActionResult> GenerateReport(int rows = 200, int delayMs = 25, CancellationToken token = default)
{
    try
    {
        var summary = await _reportService.GenerateReportAsync(rows, delayMs, token);
        return Ok(summary);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine($"Request cancelled by client");
        return StatusCode(StatusCodes.Status499ClientClosedRequest, new { Message = "Client cancelled the request." });
    }
}


public async Task<object> GenerateReportAsync(int rows, int delayMs, CancellationToken ct)
{
    var started = DateTimeOffset.UtcNow;
    var processed = 0;

    try
    {
        for (int i = 0; i < rows; i++)
        {
            ct.ThrowIfCancellationRequested();
            await Task.Delay(delayMs, ct); // simulate row processing
            processed++;
        }
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine($"Report generation cancelled");
        throw;
    }

    return new
    {
        StartedAt = started,
        CompletedAt = DateTimeOffset.UtcNow,
        RowsProcessed = processed,
        DelayPerRowMs = delayMs
    };
}

Trigger the GET endpoint (https://localhost:7269/api/Demo/reports/generate?rows=200&delayMs=25) via Postman and cancel the request midway. Cancellation can be verified from the console logs:

Report generation cancelled
Request cancelled by client

Note: Swagger vs Postman Cancellation Behavior
You might notice that cancellation works differently between Swagger UI (browser) and Postman:

  • Postman: When you hit Cancel, Postman immediately aborts the TCP connection (sends a RST/FIN), so the server detects disconnect instantly → HttpContext.RequestAborted fires and you see logs immediately.

  • Swagger UI (browser): Browsers send API calls via fetch()/AJAX. When you cancel in Swagger, the browser stops listening but doesn’t always close the underlying TCP connection right away (because of connection pooling, keep-alive, etc.). This means the server may not see the cancellation until later (or sometimes not at all).

In short: Postman = explicit cancel, Swagger = passive cancel.
This is expected behavior — so don’t get confused if you only see cancellation logs with Postman.


Timeout With CancelAfter

Sometimes, you want to enforce your own timeout regardless of the client. That’s where CancellationTokenSource.CancelAfter() comes in.

[HttpPost("timeout/run")]
public async Task<IActionResult> RunWithTimeout(int timeoutMs = 2000, CancellationToken token = default)
{
    var result = await _timeoutDemo.RunWithTimeoutAsync(timeoutMs, token);
    return Ok(result);
}


public async Task<object> RunWithTimeoutAsync(int timeoutMs, CancellationToken outer)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(outer);
    cts.CancelAfter(TimeSpan.FromMilliseconds(timeoutMs));

    var sw = System.Diagnostics.Stopwatch.StartNew();
    try
    {
        // Simulated IO (3s)
        await SimulatedIoAsync(3000, cts.Token);
        sw.Stop();
        return new { Completed = true, ElapsedMs = sw.ElapsedMilliseconds, TimedOut = false };
    }
    catch (OperationCanceledException)
    {
        sw.Stop();
        return new { Completed = false, ElapsedMs = sw.ElapsedMilliseconds, TimedOut = true };
    }
}


private static async Task SimulatedIoAsync(int ms, CancellationToken ct)
    => await Task.Delay(ms, ct);

Trigger the POST endpoint (https://localhost:7269/api/Demo/timeout/run?timeoutMs=2000), the response will be:


Linked Tokens (Timeout + Client Abort)

Sometimes you need cancellation to occur from either a timeout OR the client disconnect. In that case, you can link multiple tokens.

[HttpPost("linked/search")]
public async Task<IActionResult> LinkedSearch(int timeoutMs = 1000, CancellationToken token = default)
{
    var result = await _timeoutDemo.LinkedSearchAsync(timeoutMs, token);
    return Ok(result);
}


public async Task<object> LinkedSearchAsync(int timeoutMs, CancellationToken clientAbort)
{
    using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(timeoutMs));
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, clientAbort);

    var sw = System.Diagnostics.Stopwatch.StartNew();
    try
    {
        // Simulate a long-running operation in 10 steps (each ~400ms)
        for (int i = 0; i < 10; i++)
        {
            linked.Token.ThrowIfCancellationRequested();
            await Task.Delay(400, linked.Token);
        }
        sw.Stop();
        return new { Completed = true, ElapsedMs = sw.ElapsedMilliseconds, Reason = "All steps done" };
    }
    catch (OperationCanceledException)
    {
        sw.Stop();

        string reason;
        if (timeout.IsCancellationRequested && !clientAbort.IsCancellationRequested)
        {
            reason = "Timeout";
            Console.WriteLine($"Request cancelled due to TIMEOUT");
        }
        else if (clientAbort.IsCancellationRequested)
        {
            reason = "ClientAborted";
            Console.WriteLine($"Request cancelled by CLIENT");
        }
        else
        {
            reason = "LinkedCancel";
            Console.WriteLine($"Request cancelled for unknown linked reason after {sw.ElapsedMilliseconds}ms");
        }

        return new { Completed = false, ElapsedMs = sw.ElapsedMilliseconds, Reason = reason };
    }
}

Case A: Timeout Expires

Trigger the POST endpoint (https://localhost:7269/api/Demo/linked/search?timeoutMs=1000), we will get the following on the console:

Request cancelled due to TIMEOUT

Case B: Cancel Request Midway

Trigger the POST endpoint (https://localhost:7269/api/Demo/linked/search?timeoutMs=1000) from postman and cancels the request, we will get the following on the console:

Request cancelled by CLIENT

Parallel Tasks With Cooperative Cancellation

In real-world scenarios like web scraping, data aggregation, or batch processing, we often launch multiple tasks in parallel. The challenge is:
What if one of them fails (e.g., blocked by an anti-bot or API error)?

It usually doesn’t make sense to keep the rest running. Instead, we want to cancel all the others cooperatively once any one fails.

We expose a POST endpoint where the client can request n scrapers with a configurable per-step delay.

[HttpPost("parallel/scrape")]
public async Task<IActionResult> ScrapeMany(int n = 5, int delayMs = 800, CancellationToken token = default)
{
    var result = await _parallelTasksDemo.ScrapeManyAsync(n, delayMs, token);
    return Ok(result);
}


public async Task<object> ScrapeManyAsync(int n, int delayMs, CancellationToken requestAborted)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
    var token = cts.Token;

    var tasks = Enumerable.Range(1, n).Select(i => ScrapeOneAsync(i, delayMs, token)).ToArray();

    try
    {
        // If any task throws, we cancel all and rethrow to shape the response.
        var all = Task.WhenAll(tasks);
        await all;
        return new
        {
            Completed = true,
            Results = tasks.Select(t => t.Result).ToArray()
        };
    }
    catch (Exception ex)
    {
        cts.Cancel();

        var results = tasks
            .Where(t => t.IsCompletedSuccessfully)
            .Select(t => t.Result)
            .ToArray();

        return new
        {
            Completed = false,
            Canceled = true,
            SucceededCount = results.Length,
            Message = $"One or more tasks canceled/failed → others canceled ({ex.GetType().Name})"
        };
    }
}


private static async Task<object> ScrapeOneAsync(int id, int delayMs, CancellationToken ct)
{
    // Randomly fail one to demonstrate cascade cancel
    var rnd = Random.Shared.Next(1, 10);
    var steps = Random.Shared.Next(5, 9);

    for (int i = 0; i < steps; i++)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(delayMs, ct);
        if (rnd == 7 && i == 2) // sometimes fail
            throw new InvalidOperationException($"Scraper {id} hit anti-bot.");
    }

    return new { Worker = id, Steps = steps, PerStepDelayMs = delayMs };
}

Trigger the POST endpoint (https://localhost:7269/api/Demo/parallel/scrape?n=5&delayMs=800) from postman:

Case A – All Workers Succeed

If no worker randomly fails, all tasks complete successfully and we get the combined results back.

Case B – One Worker Fails

If any worker throws an exception, we cancel all the others. The response clearly shows cancellation:


Cleanup On Cancel (File Write)

When writing files, if the process is canceled, leaving partial files is dangerous. Proper cleanup is a must.

[HttpPost("files/write")]
public async Task<IActionResult> WriteLargeFile(int sizeMb = 25, int chunkMs = 200, CancellationToken token = default)
{
    var result = await _fileWriteDemo.WriteLargeFileAsync(sizeMb, chunkMs, token);
    return Ok(result);
}


public async Task<object> WriteLargeFileAsync(int sizeMb, int chunkMs, CancellationToken ct)
{
    var fileName = $"ct-demo-{Guid.NewGuid():N}.bin";
    var tmpPath = Path.Combine(Path.GetTempPath(), fileName);

    var totalBytes = sizeMb * 1024L * 1024L;
    var chunk = new byte[1024 * 1024]; // 1MB
    Random.Shared.NextBytes(chunk);

    var sw = System.Diagnostics.Stopwatch.StartNew();
    var written = 0L;

    try
    {
        await using var fs = new FileStream(
            tmpPath,
            FileMode.CreateNew,
            FileAccess.Write,
            FileShare.None,
            bufferSize: 81920,
            useAsync: true);

        while (written < totalBytes)
        {
            ct.ThrowIfCancellationRequested();

            var remaining = (int)Math.Min(chunk.Length, totalBytes - written);
            await fs.WriteAsync(chunk.AsMemory(0, remaining), ct);
            written += remaining;

            // flush so partial data really goes to disk
            await fs.FlushAsync(ct);

            // simulate work per chunk
            await Task.Delay(chunkMs, ct);
        }

        sw.Stop();

        return new
        {
            FileName = fileName,
            SizeMB = sizeMb,
            ElapsedMs = sw.ElapsedMilliseconds,
            Canceled = false
        };
    }
    catch (OperationCanceledException)
    {
        sw.Stop();

        if (File.Exists(tmpPath))
        {
            try { File.Delete(tmpPath); } catch { }
        }

        Console.WriteLine($"Request cancelled by CLIENT");

        return new
        {
            FileName = fileName,
            SizeMB = sizeMb,
            ElapsedMs = sw.ElapsedMilliseconds,
            Canceled = true
        };
    }
}

Trigger the POST endpoint (https://localhost:7269/api/Demo/files/write?sizeMb=25&chunkMs=100) from postman and cancel the request. Cancellation can be verified from console logs:

Request cancelled by CLIENT

Background Worker + Cancellation

ASP.​NET Core background workers automatically receive a CancellationToken that triggers on host shutdown (e.g., when you press Ctrl+C or stop the app).
This allows long-running background tasks to exit gracefully instead of being killed abruptly.

public class JobItem
{
    public Guid JobId { get; set; } = Guid.NewGuid();
    public string FileName { get; set; } = default!;
    public string Status { get; set; } = "Pending";
}


public interface IJobQueue
{
    ValueTask EnqueueAsync(JobItem job, CancellationToken ct = default);
    IAsyncEnumerable<JobItem> ReadAllAsync(CancellationToken ct);
}


public sealed class ChannelJobQueue : IJobQueue
{
    private readonly Channel<JobItem> _channel = Channel.CreateUnbounded<JobItem>();

    public ValueTask EnqueueAsync(JobItem job, CancellationToken ct = default)
        => _channel.Writer.WriteAsync(job, ct);

    public async IAsyncEnumerable<JobItem> ReadAllAsync([EnumeratorCancellation] CancellationToken ct)
    {
        while (await _channel.Reader.WaitToReadAsync(ct))
            while (_channel.Reader.TryRead(out var job))
                yield return job;
    }
}


public sealed class BackgroundWorker : BackgroundService
{
    private readonly IJobQueue _queue;

    public BackgroundWorker(IJobQueue queue)
    {
        _queue = queue;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        Console.WriteLine("Background worker started.");

        try
        {
            await foreach (var job in _queue.ReadAllAsync(stoppingToken))
            {
                Console.WriteLine($"Processing {job.FileName}...");

                try
                {
                    // Simulate work
                    for (int i = 0; i < 5; i++)
                    {
                        stoppingToken.ThrowIfCancellationRequested();
                        await Task.Delay(500, stoppingToken);
                    }

                    job.Status = "Completed";
                    Console.WriteLine($"Job {job.FileName} done.");
                }
                catch (OperationCanceledException)
                {
                    job.Status = "Canceled";
                    Console.WriteLine($"Job {job.FileName} canceled.");
                }
            }
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("Worker stopping (host shutdown).");
        }
    }
}


[HttpPost("queue/enqueue")]
public async Task<IActionResult> EnqueueJobs(int count = 3, CancellationToken token = default)
{
    var jobs = new List<JobItem>();
    for (int i = 1; i <= count; i++)
    {
        var job = new JobItem { FileName = $"DEMO_{i:00}.dat" };
        await _queue.EnqueueAsync(job, token);
        jobs.Add(job);
    }

    return Ok(new { Enqueued = jobs.Select(j => j.JobId) });
}
Worker stopping (host shutdown).

Why It Matters

In real-world systems, cancellation isn’t just about stopping work early — it’s about saving resources, keeping apps responsive, and ensuring clean shutdowns. If you don’t handle it properly, you risk leaking memory, keeping database connections open, or corrupting partial files.

Conclusion

Using CancellationToken effectively adds resilience and robustness to your ASP.​NET Core applications.

We’ve covered:

  • Client-initiated cancellation

  • Timeout handling

  • Multiple linked cancellation sources

  • Cooperative cancellation in parallel tasks

  • Resource cleanup on cancel

  • Graceful background worker shutdown

These patterns not only prevent wasted compute but also make your APIs more user-friendly and predictable under load.

Source Code

Source code is available on GitHub.

0
Subscribe to my newsletter

Read articles from Waleed Naveed directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Waleed Naveed
Waleed Naveed