A long-running async operation that can't be cancelled is a liability. The user closes the tab, the request times out, the server starts shutting down, and your code keeps grinding away on work nobody wants any more. .NET's answer to this is the CancellationToken, a small object that gets passed through async call chains and signals "stop as soon as you can." This lesson covers how cancellation actually works, the difference between CancellationTokenSource and CancellationToken, polling vs throwing, timeouts, linking tokens, and the cleanup mistakes that bite people in production.
Async code starts work that may take seconds or minutes to finish. A product search hits a slow index, an inventory lookup waits on a downstream service, a checkout flow walks through payment, stock, and shipping in sequence. Plenty of things can happen between the moment that work starts and the moment it finishes. The user navigates away. The browser drops the connection. The parent operation already failed. The server received a shutdown signal and has thirty seconds to drain in-flight requests. A timeout fired because the downstream service is slow today and waiting longer doesn't help anyone.
In every one of these cases, the right answer is the same: stop. Free the thread, release the database connection, abandon the HTTP call, and don't bill the user for an order they cancelled. Code that ignores those signals wastes resources at best and corrupts state at worst.
The search was supposed to scan five shards. After the 1.2-second timeout fired, the next await Task.Delay saw the token had been cancelled and threw, which bubbled up to the catch block. The remaining shards never ran. No thread sat blocked, no connection leaked.
That's cancellation in one example. The rest of the lesson digs into what each piece does and how to wire it up without surprises.
.NET uses cooperative cancellation. A CancellationToken is a request, not a kill switch. When you cancel a token, the runtime does not interrupt the running code, terminate threads, or roll back partially-completed work. It flips a flag. The async method has to check that flag, or call a BCL method that checks it, before anything actually stops.
This design exists because forced termination is unsafe. Killing a thread mid-write can corrupt files, leave locks held, leave database transactions half-committed. Cooperative cancellation hands control back to the running code, which knows what's safe to abandon and what needs a few more milliseconds to finish cleanly.
The diagram shows the split. One CancellationTokenSource produces a single token, and that one token gets handed to every operation that should stop together. The source has the Cancel() method. The token has the read-only "are we cancelled yet?" flag. When the source is cancelled, every operation holding the token sees the same signal at the same moment.
The asymmetry is intentional. The code that owns the operation holds the source and decides when to cancel. The code that does the work only holds the token, so it can observe cancellation but can't trigger it. That separation keeps responsibility clear: one side starts and stops the work, the other side runs the work.
The two types come as a pair. CancellationTokenSource is the producer, CancellationToken is the consumer, and you should always be clear which one your method is dealing with.
new CancellationTokenSource() creates the source. cts.Token returns the associated token, which is a struct (a value type) that holds a reference back to the source's internal state. You can call cts.Token as many times as you like; you get the same logical token every time. Calling cts.Cancel() flips the flag, and every copy of the token observes the change.
A method's parameter list is the easiest place to see who has which role. A method that performs cancellable work takes a CancellationToken. A method that controls the cancellation, the one that gets to fire it, holds the CancellationTokenSource. Mixing this up by passing the source into worker methods is a leak of authority: any of those workers could now cancel its own caller's operation, which is rarely what you want.
The simplest way to honor a token is to ask "have we been cancelled?" at points where it's safe to stop. CancellationToken.IsCancellationRequested is a bool property you can check anywhere, including inside loops that don't otherwise hit a cancellation-aware API.
The loop checks IsCancellationRequested once per iteration. After the third product, the timeout has fired and the next check sees a cancelled token, so the method returns cleanly. No exception is thrown. The caller treats the return as a normal completion.
Polling is appropriate when you want to finish your unit of work cleanly before bailing out, or when you have cleanup to do that doesn't fit inside an exception. Database commits, partial result accumulation, logging the abandoned-at point, anything that needs to run before the method exits. It's also the right pattern for tight, CPU-bound loops with no async operation inside, because checking a flag is cheaper than throwing an exception.
IsCancellationRequested is a fast property read, roughly a couple of memory loads. Calling it once per iteration of any normal loop is free. In a tight loop running a billion iterations, check it every N iterations rather than every one; everywhere else the overhead is invisible.
The other way to react to a cancelled token is to throw immediately. CancellationToken.ThrowIfCancellationRequested() does exactly that: if the token is cancelled, it throws OperationCanceledException. If the token is not cancelled, it does nothing.
This is the form most async methods use. The exception unwinds the call stack through every await until something catches it, which makes cancellation propagation almost free. You don't need each layer to check a flag and decide what to return, you just let the exception travel up to whoever cares.
Use ThrowIfCancellationRequested when the method is async, has no partial result worth preserving, and the caller already expects OperationCanceledException somewhere up the stack. Use polling when you have cleanup or partial output to write before exiting, when the method should return a sensible value rather than throw, or when you're inside a CPU-bound loop and want to minimize per-iteration overhead. Most async methods in modern C# use the throwing form because most async methods don't have meaningful partial results.
When a cancelled token causes an exception, the type thrown is OperationCanceledException. It carries a CancellationToken property so the catcher can see which token caused the cancellation, useful when multiple tokens are in play.
TaskCanceledException is a subclass of OperationCanceledException. It's thrown when an awaited Task is cancelled rather than completing or faulting. For most catch blocks, catching the base type covers both.
Task.Delay saw the token go cancelled and threw TaskCanceledException, which is an OperationCanceledException, which is what the catch block looks for. The exception object also carries the token that caused it, which matches the one we owned.
A couple of details. An awaited task that's cancelled produces an OperationCanceledException at the await, not an AggregateException. The async machinery unwraps the wrapper that older blocking APIs would have shown. The exception is not a "failure" in the usual sense. Frameworks like ASP.NET Core treat it specially (the request is logged as cancelled, not as a 500 error). Treat OperationCanceledException as a successful cancellation, not a bug to investigate.
A when clause on the catch can filter by which token fired, which matters once linked sources enter the picture:
Almost every async API in the Base Class Library accepts a CancellationToken as the last parameter. HttpClient.GetAsync, Stream.ReadAsync, Task.Delay, File.ReadAllTextAsync, SemaphoreSlim.WaitAsync, EF Core's ToListAsync, ADO.NET's ExecuteReaderAsync. The convention is consistent, which makes wiring up cancellation in a long async chain mostly a matter of passing the same token through.
Output (depends on network speed):
The same token threads through GetAsync and ReadAsStringAsync. If the network call hangs, the timeout fires, both calls see the cancellation, and OperationCanceledException bubbles out. You don't write any cancellation logic yourself. Take a token as a parameter, pass it to every async BCL call you make, and trust those calls to throw when it fires.
Task.Delay(TimeSpan, CancellationToken) is especially handy because it gives you a cancellable wait. Task.Delay(TimeSpan) without the token would hold the timer until it fires regardless of cancellation, which means the rest of your method waits even though the work has been abandoned.
A timeout is just a cancellation with a deadline. CancellationTokenSource has two constructors and one method for this: pass a TimeSpan to the constructor for "cancel after this long", or call CancelAfter later to set or update the deadline.
The work would have taken three seconds. The timeout fires at one. Same exception type, same propagation path as a manual cts.Cancel(). From the worker method's perspective, it can't tell whether the timeout fired or a caller cancelled explicitly, and most of the time it doesn't need to.
CancelAfter is useful when you want to set or adjust the timeout after the source has been created. Calling it replaces any prior pending timeout (the second call resets the timer, it doesn't cancel twice). Passing Timeout.InfiniteTimeSpan clears the pending timeout entirely. Once a token is cancelled, further calls to CancelAfter or Cancel do nothing. A cancelled source stays cancelled.
A single operation often has more than one reason to cancel. The user might cancel it. A timeout might fire. The application might be shutting down. You want all three reasons to converge on one token, so the worker method only has to check one thing.
CancellationTokenSource.CreateLinkedTokenSource builds a new source that cancels itself whenever any of its input tokens cancel.
In this run, both reasons stayed quiet long enough for the order to complete. Drop the timeout to 300 milliseconds, or call userCts.Cancel() mid-flow, and the same code reacts to either signal because both feed the same linked token. The exception carries the linked token, which is fine for "we cancelled" but doesn't directly say which input caused it, checking each source's IsCancellationRequested after the catch gives you that answer.
A note on framework integration. ASP.NET Core gives controllers a HttpContext.RequestAborted token that cancels when the client disconnects. Hosted services get a stopping token from the host. If you need a per-operation timeout on top of those, take the framework's token, link it with your own timeout source, and pass the linked token down.
CancellationToken.Register runs a callback when the token is cancelled. It's useful for cleanup that isn't part of the cancellable operation itself: logging the cancellation, releasing a non-async lock, signaling another component, cancelling something that doesn't accept a token directly.
The cleanup ran before the catch block. Register returns a CancellationTokenRegistration value type. Disposing the registration unhooks the callback, which is what using does at the end of the method. If you don't dispose it, the callback stays registered for the source's lifetime, which leaks memory when the same source is reused for many operations.
Three rules govern callback behavior. The callback runs on whichever thread calls Cancel, so don't assume a specific synchronization context. If the token is already cancelled when you call Register, the callback runs synchronously on the calling thread before Register returns. Exceptions thrown from a callback propagate to the caller of Cancel, which is rarely what you want, keep callbacks short and don't throw from them.
CancellationTokenSource implements IDisposable, and forgetting to dispose it is one of the most common cancellation bugs in production code. The leak isn't dramatic, the source is small, but it's still a leak, and linked sources amplify the problem.
Both calls succeed. The difference shows up under load. BadAsync leaves the source alive until the GC notices, which on a server handling thousands of requests per second means thousands of leftover sources sitting in memory, each holding a timer registration, each waiting for collection. Over time the working set grows, GC pauses get longer, and you eventually trace the bloat back to a missing using.
Linked sources need disposal too, and they need it more urgently. The linked source registers callbacks on each input token. If you never dispose the linked source, those callbacks stay alive on the input sources, preventing the inputs from being collected even after you've moved on.
The pattern: every CancellationTokenSource created with new or CreateLinkedTokenSource lives inside a using or using var block. The only exception is a long-lived field on an object that has its own Dispose method, in which case dispose it there.
Each undisposed CancellationTokenSource keeps its internal state and any pending timer alive. At low volumes it's invisible. At thousands of allocations per second, the unreleased timers and callback lists cost memory and CPU. Always pair new CancellationTokenSource(...) with using.
Common cancellation bugs:
Forgetting to pass the token down. Accepting a token in a method signature, then calling Task.Delay(500) without passing it. The delay isn't cancellable. Same applies to HttpClient.GetAsync, Stream.ReadAsync, anything that takes an optional token. After accepting a token, pass it through every async call.
Swallowing OperationCanceledException. A blanket catch (Exception) that logs and continues turns cancellation into "the work silently kept running but pretended to fail." Catch OperationCanceledException first and rethrow it, then catch real failures separately.
Using `default` when a real token is available. Passing CancellationToken.None or default into a worker method when you have a real token is the cancellation equivalent of catching and ignoring an exception. The worker runs uncancellable, which surprises callers who set timeouts they expect to be honored.
Sharing one CancellationTokenSource across unrelated operations. A source represents one logical operation's lifetime. Two unrelated requests should not share a source, cancelling one would cancel the other.
Forgetting to dispose linked sources. Linked sources allocate callbacks on their input tokens. Undisposed linked sources leak memory in proportion to throughput.
Calling `cts.Cancel()` inside a callback registered with `Register`. The callback runs during Cancel's execution. Recursive cancellation inside a callback can deadlock or stack-overflow depending on the synchronization rules. If you want one source's cancellation to imply another's, that's exactly what CreateLinkedTokenSource is for.
10 quizzes