CancellationToken in ASP.NET Core – A Complete Guide


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) });
}
Trigger the POST endpoint (https://localhost:7269/api/Demo/queue/enqueue?count=3)→ console will log job processing.
Stop the app with Ctrl+C while jobs are running → cancellation logs can be seen on the console:
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.
Subscribe to my newsletter
Read articles from Waleed Naveed directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
