A few years ago, I wrote a long post on CancellationToken covering what it is, how it works, and some common patterns while using it. That post did fairly well, and I still think it is a good introduction to the topic.
But over time, after writing more backend code and dealing with real services, I realised that the most interesting parts of cancellation are not in the basics. They show up when requests get aborted midway, workers need to shut down gracefully, one timeout starts fighting another, and some innocent looking async method keeps running long after the caller has stopped caring.
This post is about that practical side of cancellation.
This is not another introduction to CancellationToken, and I am not going to repeat every API on the type. Instead, this is a more practical follow-up on how cancellation behaves in real systems, especially in backend services and APIs where it can save resources, improve responsiveness, and occasionally expose some very avoidable mistakes.
If you want the fundamentals first, I would recommend reading my earlier post before continuing. If you already know the basics, then let’s move on to where things start getting interesting.
A quick refresher
At its core, a CancellationToken is just a cooperative signal.
It does not magically kill a running operation. It does not reach into your method, pull the plug, and heroically clean up your mess. It simply gives one part of the system a way to say, “Hey, if you are still doing work, it would be nice if you could stop now.” It is then up to the operation doing the work to observe that signal and respond appropriately.
That distinction matters a lot in real systems.
If your code ignores the token, the operation just keeps running. If your code checks for cancellation too casually at the wrong time, you can leave things in a half-finished state. And if you wire the token in at the top of the stack but forget to pass it to anything underneath, then your cancellation support is mostly decorative.
So while CancellationToken looks simple, it quickly becomes a design concern instead of just a method parameter.
Request cancellation in ASP.NET Core
One of the first places where cancellation becomes genuinely useful is request handling.
In ASP.NET Core, a request can get aborted for a variety of reasons. The client may have disconnected, refreshed the page, navigated away, or simply timed out and given up. Once that happens, continuing expensive work on the server is usually not helping anyone. The caller is gone, the response is not going anywhere useful, and your application is now just burning CPU time, database connections, or downstream HTTP calls for no real benefit.
That is where HttpContext.RequestAborted comes in — a CancellationToken tied to the lifecycle of the request. If the request is aborted, this token gets cancelled.
You can access it directly through HttpContext if you need to, but most of the time you do not have to. Both minimal APIs and controller actions support binding a CancellationToken parameter directly, and ASP.NET Core wires it to HttpContext.RequestAborted for you:
app.MapGet("/reports/{id}", async (
string id,
CancellationToken cancellationToken,
ReportsService reportsService) =>
{
var report = await reportsService.GenerateAsync(id, cancellationToken);
return Results.Ok(report);
});
The same applies to controller actions:
[HttpGet("{id}")]
public async Task<IActionResult> GetReport(
string id,
CancellationToken cancellationToken)
{
var report = await _reportsService.GenerateAsync(id, cancellationToken);
return Ok(report);
}
From there, the important thing is not just that the endpoint has the token, but that it passes it through to the rest of the call chain.
A very common real-world example here is some kind of expensive aggregation or report generation endpoint. Maybe the request triggers a few downstream calls, a database query, and some in-memory processing before the final response is assembled. If the caller disconnects after two seconds, but your backend happily continues for another fifteen, you have just done a lot of work for absolutely no user-visible benefit.
That is not just wasteful, it can become surprisingly expensive at scale.
One thing that pairs well with request cancellation is a simple middleware that catches OperationCanceledException at the edge, so aborted requests do not end up in your error logs looking like server failures:
app.Use(async (context, next) =>
{
try
{
await next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// Client disconnected — not an error, nothing to log.
}
});
The nice thing is that ASP.NET Core already gives you the cancellation signal. The only hard part is respecting it consistently.
Forward the token or the whole thing falls apart
A cancellation-aware endpoint is a good start, but it is only a start.
The real value comes from propagating the token through the entire call chain:
- service layer
- repository layer
- outbound HTTP calls
- EF Core queries
- queue or storage SDK calls
If the API entry point accepts a token and the next two methods quietly drop it, then the cancellation support exists mostly for decoration.
public async Task<ReportDto> GenerateAsync(string id, CancellationToken cancellationToken)
{
var metadata = await _metadataClient.GetAsync(id, cancellationToken);
var rows = await _repository.LoadRowsAsync(id, cancellationToken);
return ReportDto.From(metadata, rows);
}
This sounds obvious, but it is one of the easiest mistakes to make in a layered backend. You do the “correct” thing at the top by accepting a token, and then somewhere in the middle of the call chain someone forgets to pass it along. At that point, the rest of the code is effectively deaf to cancellation.
Sometimes this happens because a method never accepted a token in the first place. Sometimes it happens because someone passes CancellationToken.None to a downstream call “just for now” and then nobody revisits it. Sometimes the library method accepts a CancellationToken as an optional parameter with a default value, so it never even occurs to you that you should be passing one in. And sometimes it happens because the token gets propagated to one dependency but not the other, which is even more fun to debug because now cancellation works only on alternate Tuesdays.
In practice, forwarding the token consistently is often more important than manually checking IsCancellationRequested in a hundred places. If your downstream HTTP call, EF Core query, or storage SDK already accepts a token, let it do the right thing.
The simple rule I try to follow is this: if a method accepts a CancellationToken, it should usually pass that token to any cancellable async work it starts, unless there is a very deliberate reason not to.
Timeouts are not the same thing as cancellation
One place where things get muddled very quickly is timeouts.
A timeout is a policy decision: this operation should not take longer than X. A cancellation token is the mechanism used to communicate that decision.
These two things often appear together, but they are not the same.
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
timeoutCts.Token);
await _client.GetAsync(uri, linkedCts.Token);
In the example above, the operation can be cancelled for at least two reasons:
- the original caller requested cancellation
- the timeout expired
Those are very different events, even though both eventually show up through a cancellation token.
That distinction matters because the meaning is different. If a user cancels a request, that is usually expected control flow. If your own timeout triggers, that might point to a slow dependency, an unrealistic timeout value, or a larger reliability issue. Treating both cases as identical can make logs and diagnostics a lot less useful.
This is also why linked tokens are so useful in real systems. They let you combine multiple cancellation sources into one token that you can pass downstream, while still retaining the ability to inspect the original sources and figure out what actually happened.
The broader lesson here is that cancellation is the transport, not the reason. A cancelled operation tells you that something asked the work to stop. It does not automatically tell you why.
Linked tokens and multiple reasons to stop
Real systems often have more than one reason to stop work:
- the client disconnected
- an operation timed out
- the host is shutting down
- an outer orchestration was cancelled
That is where linked tokens stop being an obscure feature and start being genuinely useful.
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
httpContext.RequestAborted,
timeoutCts.Token,
stoppingToken);
await DoSomeWorkAsync(linkedCts.Token);
The nice thing here is that downstream code gets one token to care about instead of three different cancellation sources. That keeps the call chain cleaner and makes it easier to propagate cancellation consistently.
The slightly less nice thing is that once the linked token is cancelled, it does not automatically tell you which of the original sources fired first. If you care about the reason, you still have to inspect the original tokens.
That becomes important surprisingly quickly.
If the request was aborted, that is usually normal control flow. If your timeout token fired, that might mean the dependency is slow or your timeout is too aggressive. If the host shutdown token fired, then the system is trying to stop cleanly and your logging should probably reflect that instead of pretending the application has entered a state of theatrical failure.
So linked tokens are great for propagation, but when cancellation actually happens, it is still worth asking: who asked for this?
In practice, that usually means checking the original tokens individually:
try
{
await DoSomeWorkAsync(linkedCts.Token);
}
catch (OperationCanceledException)
{
if (httpContext.RequestAborted.IsCancellationRequested)
_logger.LogInformation("Client disconnected");
else if (timeoutCts.Token.IsCancellationRequested)
_logger.LogWarning("Operation timed out");
else if (stoppingToken.IsCancellationRequested)
_logger.LogInformation("Host is shutting down");
}
It is not the most glamorous pattern, but it gives you the clarity you need when reading logs later. Just keep in mind that this kind of inspection belongs at the boundary — the outer handler or middleware — not buried deep inside the call chain where swallowing the exception would quietly eat the cancellation signal.
Background services and graceful shutdown
Cancellation matters just as much outside request/response code.
In background workers, cancellation is often the difference between a clean shutdown and a process that behaves like it never received the memo.
public sealed class Worker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await DoWorkAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
The stoppingToken passed into ExecuteAsync is the host’s way of saying, “Please wrap this up now.” If your background service ignores that token, then shutdown becomes slower, messier, and much more confusing than it needs to be.
This is one reason I like BackgroundService as an example when talking about cancellation. The code tends to look harmless: a loop, some work, maybe a delay between iterations. But if the token is not checked or passed along, that harmless little loop becomes the thing delaying shutdown while everyone else is trying to go home.
A few common mistakes show up here repeatedly:
- forgetting to pass the token into
Task.Delay - using blocking waits or sleeps that cannot be cancelled
- passing the token into the outer method, but not into the actual async work underneath
- catching
OperationCanceledExceptionand logging it like the datacenter just exploded
Graceful shutdown is one of those things that feels boring right up until the first time a service refuses to stop cleanly in production. After that, it becomes very interesting very quickly.
What should actually be cancellable?
Not every piece of code needs to observe cancellation at every line, and not every operation should happily stop midway just because a token was cancelled.
There are some very obvious good candidates for cancellation:
- outbound HTTP calls
- database queries
- queue polling
- blob downloads or uploads
- long-running calculations
- waits and delays
In all of these cases, if the caller no longer cares about the result, continuing the work is often just wasted effort.
But there is another category of operations where you need to be more careful:
- critical cleanup
- side effects that may already have committed
- transaction boundaries
- compensation logic
- operations that have already crossed a “point of no return”
This is the part that usually matters most in real systems.
If you are halfway through some expensive read operation, cancellation is often great. If you are halfway through writing state to multiple systems, cancellation may be much less helpful. In those situations, the more important question is not “Can I cancel this?” but “What state will I leave things in if I do?”
That is why I think it helps to treat cancellation as a design decision rather than just a plumbing exercise. Sometimes the right answer is to keep honoring cancellation all the way down. Sometimes the right answer is to stop honoring it after a certain point, finish a critical section, and only then return control.
This is the practical boundary that shows up later: once an operation crosses the point where stopping would leave the system inconsistent or harder to recover, it may be better to finish cleanly than to stop immediately.
The usual suspects
After working with CancellationToken for a while, certain mistakes keep showing up. I have made most of these myself at some point, and I keep spotting them in code reviews. Here are the ones worth watching for.
In other words, CancellationToken is a very good tool, but it still expects the rest of us to behave like adults.
A practical pattern I like
By this point, the individual pieces are hopefully familiar enough. So here is the simple pattern I try to follow in backend code:
- Accept the token at the boundary.
- Pass it through the full call chain.
- Link it with timeout or shutdown tokens when needed.
- Treat cancellation as normal control flow, not instant drama.
- Be deliberate about where cancellation should stop being honored.
That may not sound revolutionary, and that is kind of the point. The value of CancellationToken in real systems is usually not in doing something clever. It is in being consistent.
If the request token comes in at the edge, pass it through. If an operation needs its own timeout, link it in. If a worker is shutting down, honor the shutdown token. And if the code has crossed into a critical section where stopping midway would create more problems than it solves, make that a deliberate design choice instead of an accident.
That is the pattern I have found most useful over time. Not a bag of isolated cancellation tricks, but a fairly boring and reliable discipline around how cancellation flows through the system.
Closing thoughts
CancellationToken is one of those features that looks small in reading and gets more interesting the longer you build real systems.
On the surface, it is just a parameter and a signal. In practice, it affects responsiveness, resource usage, timeout behavior, shutdown quality, and how much pointless work your backend does after the caller has already moved on with their life.
The earlier post focused on how cancellation works. This one is really about where it matters. And the more I work on backend systems, the more I think that is the useful part.
A lot of APIs in .NET are easy to use correctly in toy examples and surprisingly easy to use poorly in production. CancellationToken is definitely one of them. But if you treat it as part of system design instead of a ceremonial parameter you add because IntelliSense suggested it, it becomes a genuinely useful tool.
And more importantly, it helps your code stop doing work for people who have already stopped waiting for it. Which, if nothing else, is just good manners.