How .NET 9 Handles Async Cancellation and What’s New in .NET 10

You pass CancellationToken into almost every async API in a modern .NET system. HTTP handlers, EF Core queries, queue consumers, background services. You expect everything to stop cleanly when the token is cancelled.
That only works if you understand what the runtime actually does with that token.
In .NET 9 the public surface is the same as earlier versions, but the runtime around it is faster and more predictable. Task continuations are cheaper, I/O paths are more efficient, and async state machines produce less noise. The cancellation model, however, has not changed. It still uses a CancellationTokenSource, a list of callbacks and a cooperative contract between your code and the runtime.
This post walks through how cancellation behaves in .NET 9, where the real costs are, and how you should design cancellable code. At the end you get a short section on what .NET 10 adds on top.
What a CancellationToken really is
CancellationToken is a small struct. It carries a reference to a CancellationTokenSource plus a flag used for the fast path when cancellation has already happened. The heavy logic lives in the source, not the token.
A CancellationTokenSource keeps track of a Boolean “is cancelled” field, a linked list of callback registrations, a wait handle for older blocking usage, some locking and disposal state. When you make a token from a CTS, the token just points at that source. When the source cancels, every token that came from it sees the same state.
A token created with the default constructor has no source at all. It can never be cancelled. That is useful in tests or for APIs that want “optional cancellation”.zz
What happens when you call Cancel
When you call cts.Cancel() in .NET 9 the sequence is simple.
The CTS flips the internal flag that says “cancellation requested”. It publishes that change so other threads can see it. Then it walks the linked list of registered callbacks and invokes them one by one. Those callbacks run on the cancelling thread unless you used the overload that asks for asynchronous invocation.
If a registration is added after cancellation has started, the runtime will invoke that new callback immediately. That rule is important for predictability. It means you can safely register and still observe a consistent cancelled state.
This is why cancellation can be surprisingly expensive: if you have many registrations and each callback does real work, the thread that called Cancel has to run through them all before it returns.
How async APIs wire in cancellation
Tokens are not bound to tasks in any magic way. They are just passed into async APIs that decide what to do with them.
Take the common pattern:
await Task.Delay(TimeSpan.FromSeconds(5), token);
Task.Delay sets up an internal timer. It also registers a callback with the token. If the token is cancelled before the timer fires, that callback will complete the delay task early in a cancelled state. If the timer fires first, the callback is never used.
Other async APIs follow the same basic pattern. They register a callback that either breaks out of the wait or completes a task. That is true for things like Task.WhenAny, many I/O calls, and pieces of ASP.NET Core that wire request aborts into your pipeline.
The important bit is this, your code is not polling the token. The registration pushes cancellation into the operation. That is powerful, but each registration has a cost.
The cost of CancellationTokenRegistration
Every time an API “hooks” a token it allocates a CancellationTokenRegistration. That registration usually captures a delegate, which often brings a closure object with it. The CTS must also keep a linked list node for this registration.
In a cold path this is fine. In a hot path it kills you.
If you have code like this:
foreach (var item in items)
{
await ProcessAsync(item, token);
}
and ProcessAsync internally registers a callback with the token, you allocate one registration for every iteration. When cancellation finally occurs, the cancelling thread has to walk all of those registrations to invoke the callbacks. That turns cancellation into O(n) work where n is the number of outstanding operations.
You cannot rely on the JIT to remove these allocations.
Why you should pass tokens everywhere
CancellationToken is cheap to pass. It is essentially two fields. Not passing it is where the real cost appears.
If your pipeline has methods that simply do not accept a token, then those methods cannot participate in cooperative cancellation. You end up with zombie work that continues after a request has been cancelled or a job has been stopped. You also end up writing wrappers and shims just to get the token into the right place.
In .NET 9 you should treat CancellationToken as part of the standard async method signature. Every async method either accepts a token or is intentionally fire and forget with clear documentation. There is no middle ground.
Cooperative cancellation in real code
The runtime will never force your code to stop. Cancellation in .NET is always cooperative. That means you choose the points where you respect the token and give up.
The simplest pattern looks like this:
public async Task ProcessBatchAsync(CancellationToken stopToken)
{
stopToken.ThrowIfCancellationRequested();
foreach (var item in LoadItems())
{
stopToken.ThrowIfCancellationRequested();
await ProcessItemAsync(item, stopToken);
}
}
You check the token at the start so you avoid doing work if the caller already cancelled. You check inside loops so long running processes can stop in a reasonable amount of time. You pass the token to dependent async calls so that those calls can also cooperate.
You should let OperationCanceledException bubble up in most cases. It is the signal that cooperative cancellation worked as expected. If you catch it you usually re-throw it or convert it into a controlled cancel path that your higher level workflow understands. Treating it as a normal error is a mistake.
Linked tokens and timeouts
Most real systems have layered lifetimes. A web request might run for thirty seconds but a single HTTP call inside it should give up after five. A job scheduler might run for hours but individual jobs might have a much shorter budget.
Linked tokens exist for this scenario.
You can create a CTS that cancels when any of several parents cancel:
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
requestToken,
timeoutCts.Token);
await CallExternalServiceAsync(linkedCts.Token);
This combined token cancels if the request token cancels or if the timeout elapses. Under the hood this is just more registrations. When either parent fires, the linked CTS cancels and runs its own callbacks.
In .NET 9 this behaviour is unchanged from earlier versions. You still pay for the registrations, but you get clear semantics for your layered lifetimes.
Designing cancellable workflows
Think about a workflow like a pipeline with a clear lifetime. An HTTP request, a message handler, a background job iteration. At the start of that lifetime you receive or create a token. From that point on everything should be expressed in terms of that token.
A typical pattern:
public async Task RunWorkflowAsync(Guid id, CancellationToken stopToken)
{
stopToken.ThrowIfCancellationRequested();
var data = await repository.LoadAsync(id, stopToken);
var validated = await validator.ValidateAsync(data, stopToken);
await processor.ProcessAsync(validated, stopToken);
}
The rule is simple. Once you have a token, you do not lose it. You only wrap it with a linked token when you introduce a new constraint like a sub timeout.
The bad pattern is starting background work that continues after the token’s lifetime, or creating internal tasks that ignore the token entirely. That is how you end up with shutdown delays and inconsistent state.
Classic problems to avoid
A few patterns still cause trouble in .NET 9.
One is registering callbacks inside tight loops. You already saw why that matters. Another is using CancelAfter repeatedly in loops, which thrashes timers and creates noisy cancellation behaviour. A third is wrapping blocking I/O in Task.Run while passing a token that the inner work cannot observe. The outer task cancels, but the thread continues to block.
The last one is subtle but common: sharing a single CTS across unrelated operations. Once any one of them calls cancel, everything that uses that source reacts. In a large system this looks like random cancellations that are very hard to trace back.
Summary for .NET 9
In .NET 9 cancellation is still built around a simple model, a token struct as a view, a source that tracks state and callbacks, and a cooperative contract that depends on your code doing the right thing.
When you understand that registrations allocate and cancellation walks a list of callbacks, you stop abusing registration in hot paths. When you accept that the runtime will not kill your code for you, you write explicit checks in loops and pass tokens everywhere. That is enough to build cancellation that is predictable, cheap and safe.
Everything you have just seen applies directly to .NET 9. Now we can talk about what changes, and what does not, in .NET 10.
What changes in .NET 10
The semantics of CancellationToken and CancellationTokenSource do not change in .NET 10. The same data structures, the same registration model, the same cooperative rules. The official task cancellation guidance is unchanged and still describes the same pattern of throwing OperationCanceledException with the right token and letting the task transition to the cancelled state.
What does change is the environment around cancellation.
The runtime introduces new async optimisations that can bypass traditional state machine generation for some patterns, especially when you opt into pooling builders for ValueTask. These improvements reduce overhead around continuations and improve throughput, but they still rely on the same cancellation callbacks underneath.
The base class libraries add more async APIs that accept tokens. A concrete example is the new asynchronous ZIP archive methods that let you read and write large archives without blocking threads, and they integrate cancellation like any other I/O bound operation.
Another visible change is that LINQ style operators for IAsyncEnumerable<T> are part of the platform in .NET 10. That means patterns like WithCancellation and [EnumeratorCancellation] are first class. You can stream results with async LINQ and have cancellation flow cleanly down into the enumerator without inventing your own patterns.
All of this makes well designed cancellation code work better, not differently. Tokens still carry a reference to a source. Sources still manage registrations and callback lists. Cancellation still runs callbacks on the cancelling thread. Your responsibilities as a library or application author do not change. You still pass tokens through every async method, you still avoid per-iteration registrations in hot loops, and you still treat OperationCanceledException as a controlled outcome rather than a generic failure.
If you already have a solid .NET 9 mental model for cancellation, you can keep it for .NET 10.





