Hello. In the previous lesson, you used modern pattern matching to make decision logic explicit and reviewable. This lesson shifts from which decision the code makes to when the caller no longer needs the work at all.
In a backend, an abandoned HTTP request, a command-line timeout, or controlled shutdown should not leave database queries, outbound calls, file transfers, and loops consuming resources unnecessarily. By the end of this lesson, you will be able to implement an asynchronous I/O workflow in which one CancellationToken travels from the operation boundary through application code to the I/O APIs that can actually stop work.
Cancellation is cooperative, not forced termination
A CancellationToken is a signal that says: “the initiator no longer requires this operation; stop at a safe opportunity.” It does not kill a thread, forcibly undo a database write, or automatically interrupt code that never checks it.
Three roles make the model clear:
| Role | Responsibility | Typical example |
|---|---|---|
| Operation owner | Creates and eventually disposes a CancellationTokenSource; can request cancellation. | A console command, hosted service, or ASP.NET Core request infrastructure |
| Participant | Receives a CancellationToken and propagates or observes it. | Endpoint handler, application service, repository, API client |
| Cancellable operation | Accepts the token and reacts to it. | HttpClient.SendAsync, EF Core query methods, Task.Delay, stream I/O |
A CancellationTokenSource owns the ability to call Cancel() or schedule cancellation with CancelAfter(...). A CancellationToken is the value handed to the methods doing work. The token can observe cancellation, but it cannot initiate it.

The fact that tokens are copied by value is intentional. You can pass the same token through many methods without giving those methods authority to cancel the entire workflow. This separation is important: a repository should be able to honor a request cancellation, but it should not decide that the request itself must be cancelled.
Watch Rahul Nath’s “Cancellation Token in .NET” for a compact demonstration of this model, including why supplying the token to the actual waiting operation matters.
Cancellation Token in .NET | Exploring C# and DOTNET
Rahul Nath explains cancellation as a cooperative, end-to-end protocol rather than a local if statement. Watch it to see the relationship between the source, the token, explicit cancellation checks, and cancellable Task.Delay calls.
Watch the core model for the separation between source and token. Continue with manual observation to see IsCancellationRequested and ThrowIfCancellationRequested in a long-running method. Then watch cancellable delay; focus on why merely checking a token between iterations is less responsive than passing it to an API that supports cancellation.
Two consequences follow from this model:
- Every layer must cooperate. Passing a token into a controller but dropping it before a database or HTTP call preserves none of the intended benefit.
- Cancellation is not rollback. If cancellation occurs after an external side effect has completed, that side effect may remain. Workflows that write data or call external services still need sound transactional and idempotency design.
The propagation rule: accept it, pass it on
For application and infrastructure methods that participate in an existing operation, put CancellationToken last in the parameter list and forward it to every downstream method that supports it.
public interface IGameCatalogClient
{
Task<GameDefinition> GetDefinitionAsync(
string gameId,
CancellationToken cancellationToken);
}
public interface IPlayerProfileRepository
{
Task<PlayerProfile?> FindAsync(
Guid playerId,
CancellationToken cancellationToken);
Task SaveAsync(
PlayerProfile profile,
CancellationToken cancellationToken);
}
Requiring the parameter on internal methods is useful. It makes a missed propagation path visible at the call site rather than silently allowing the code to run with CancellationToken.None.
A common error is creating a new token source inside a lower layer:
public async Task<PlayerProfile?> FindAsync(
Guid playerId,
CancellationToken cancellationToken)
{
using var source = new CancellationTokenSource();
return await QueryDatabaseAsync(playerId, source.Token);
}
This disconnects the database query from the caller’s token. If the HTTP client disconnects, the original cancellationToken is cancelled, but source.Token is not. The repository has accidentally replaced the caller’s cancellation policy with a new, unrelated one.
The correct default is simpler:
public Task<PlayerProfile?> FindAsync(
Guid playerId,
CancellationToken cancellationToken) =>
QueryDatabaseAsync(playerId, cancellationToken);
Create a CancellationTokenSource only at an orchestration boundary where your code owns a new cancellation policy: for example, a command with a fixed deadline or a background job controlled by the host. If a workflow must honor multiple signals, such as caller cancellation and a local timeout, combine them at that boundary rather than inventing sources inside repositories and clients.
A complete asynchronous I/O path
Consider a portfolio-game-backend operation that refreshes a player’s profile using an external game-catalog service. The workflow has three components:
- An endpoint receives a request and its cancellation token.
- An application service loads state, calls an external API, and saves the updated profile.
- An HTTP client sends the token into the network request and response-stream deserialization.
At the ASP.NET Core boundary, a CancellationToken parameter represents the request-aborted signal. You do not need to obtain HttpContext.RequestAborted manually for this ordinary case.
app.MapPost(
"/players/{playerId:guid}/profile/refresh",
async (
Guid playerId,
ProfileRefreshService service,
CancellationToken cancellationToken) =>
{
var result = await service.RefreshAsync(
playerId,
cancellationToken);
return Results.Ok(result);
});
The endpoint does not create a new source. It forwards the framework-provided token.
The application service preserves the same token across all participating calls:
public sealed class ProfileRefreshService
{
private readonly IPlayerProfileRepository _profiles;
private readonly IGameCatalogClient _catalogClient;
public ProfileRefreshService(
IPlayerProfileRepository profiles,
IGameCatalogClient catalogClient)
{
_profiles = profiles;
_catalogClient = catalogClient;
}
public async Task<ProfileRefreshResult> RefreshAsync(
Guid playerId,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var profile = await _profiles.FindAsync(
playerId,
cancellationToken);
if (profile is null)
{
throw new KeyNotFoundException(
$"Player '{playerId}' was not found.");
}
var definition = await _catalogClient.GetDefinitionAsync(
profile.GameId,
cancellationToken);
profile.ApplyLatestGameDefinition(definition);
cancellationToken.ThrowIfCancellationRequested();
await _profiles.SaveAsync(profile, cancellationToken);
return new ProfileRefreshResult(
profile.PlayerId,
profile.GameId,
profile.LastRefreshedAtUtc);
}
}
The two calls to ThrowIfCancellationRequested() have different purposes:
- The first avoids beginning work when cancellation was already requested.
- The second provides a cancellation checkpoint after local CPU work and before beginning a consequential write.
Do not add checks mechanically after every line. They matter most before expensive work, during sufficiently long CPU-bound loops, and before initiating side effects. For asynchronous I/O, passing the token into the I/O API is usually more important than manually checking the token around it.
Here is an HTTP client implementation. Notice that the token reaches both the outbound request and asynchronous JSON deserialization.
using System.Net.Http;
using System.Text.Json;
public sealed class GameCatalogClient : IGameCatalogClient
{
private readonly HttpClient _httpClient;
public GameCatalogClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<GameDefinition> GetDefinitionAsync(
string gameId,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"/games/{Uri.EscapeDataString(gameId)}");
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
using var contentStream = await response.Content.ReadAsStreamAsync(
cancellationToken);
var definition = await JsonSerializer.DeserializeAsync<GameDefinition>(
contentStream,
cancellationToken: cancellationToken);
return definition ?? throw new InvalidOperationException(
"The game catalog returned an empty response.");
}
}
This method has a clear contract:
- If the caller cancels before or during
SendAsync, the request can stop. - If the response body is large or slow, cancellation can stop the stream read and deserialization.
- If no cancellation is requested, a normal non-success HTTP response remains an error and is not misclassified as cancellation.
HttpResponseMessageand the response stream are disposed after use. The ownership and cleanup rules behindusingwill be the focus of the next lesson.
The important point is not the particular catalog domain. The same propagation shape applies to EF Core calls such as ToListAsync(cancellationToken), file operations such as CopyToAsync(..., cancellationToken), cloud SDK calls, and messaging clients.
When to check a token yourself
Many framework and library APIs already know how to act on a cancellation token. Prefer passing the token to them:
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
var response = await httpClient.GetAsync(
requestUri,
cancellationToken);
await stream.CopyToAsync(
destination,
cancellationToken);
For CPU-bound work or a library API that does not accept a token, you need explicit cooperation:
public static int CalculateSeasonScore(
IReadOnlyList<MatchResult> matches,
CancellationToken cancellationToken)
{
var total = 0;
foreach (var match in matches)
{
cancellationToken.ThrowIfCancellationRequested();
total += CalculateMatchScore(match);
}
return total;
}
For very cheap loop iterations, checking on every iteration may be unnecessary. Check at a sensible batch boundary instead:
for (var index = 0; index < events.Count; index++)
{
if (index % 500 == 0)
{
cancellationToken.ThrowIfCancellationRequested();
}
ProcessEvent(events[index]);
}
Choose the interval based on the cost of each iteration and the responsiveness expected by the caller. A loop that makes one database call per iteration should pass the token into each database call; a CPU-heavy loop should periodically observe it itself.
Cancellation usually travels as an exception
When a cancellable asynchronous operation observes a cancelled token, it normally completes by throwing OperationCanceledException. TaskCanceledException is a subclass of OperationCanceledException, so catching the base type generally handles both.
Do not catch cancellation deep inside a repository just to log it and return a fake result:
// Do not hide cancellation this way.
try
{
return await _httpClient.GetStringAsync(uri, cancellationToken);
}
catch (OperationCanceledException)
{
return "{}";
}
The caller now cannot distinguish “the operation was abandoned” from “the catalog returned an empty JSON document.” Cancellation should generally propagate to the boundary that owns the policy.
For a command-line tool that owns a timeout, that boundary might look like this:
using var cancellationSource = new CancellationTokenSource();
cancellationSource.CancelAfter(TimeSpan.FromSeconds(5));
try
{
await refreshService.RefreshAsync(
playerId,
cancellationSource.Token);
}
catch (OperationCanceledException)
when (cancellationSource.IsCancellationRequested)
{
Console.WriteLine("Profile refresh was cancelled after five seconds.");
}
CancelAfter schedules a cancellation request; it does not guarantee that an operation stops exactly at five seconds. The downstream code must still receive and honor the token.
Read Microsoft Learn’s “Cancel async tasks after a period of time” to reinforce this distinction between scheduling cancellation, awaiting the workflow, and forwarding the same token to HttpClient.
Cancel async tasks after a period of time" - C# | Microsoft Learn
Microsoft Learn’s example shows an end-to-end console workflow: a source schedules cancellation, the entry point handles the cancellation outcome, and the token reaches the actual HTTP operations.
In the opening explanation, read the cancellation overview to distinguish a scheduled request for cancellation from forced task termination. Then read the “Update application entry point” section, especially the entry point explanation. Finally, in “Complete example,” read the full Program.cs listing and trace the token from s_cts.Token, through SumPageSizesAsync, into both GetAsync and ReadAsByteArrayAsync.
In an ASP.NET Core request handler, cancellation caused by a client disconnect usually needs no special response body: the client is no longer available to receive one. Letting OperationCanceledException propagate is often appropriate, while your observability configuration can classify expected client aborts separately from server faults. Avoid translating a cancellation into an ordinary 500 error.
There are legitimate exceptions. A deliberately independent background operation may need to continue after the initiating HTTP request ends. In that case, detach it only through an explicit product and architecture decision, such as enqueuing durable work. Passing CancellationToken.None should communicate that deliberate policy, not patch over a missed propagation path.
Use CA2016 as a propagation safety net
Missed token propagation is common because many .NET APIs offer both an overload without a token and an overload with one. The compiler may accept either call, but the overload without the token breaks the cancellation path.
For example:
public async Task<PlayerProfile?> FindAsync(
Guid playerId,
CancellationToken cancellationToken)
{
return await _dbContext.PlayerProfiles
.SingleOrDefaultAsync(profile => profile.Id == playerId);
}
The method accepts a token but does not pass it to EF Core. Correct it by selecting the cancellable overload:
public async Task<PlayerProfile?> FindAsync(
Guid playerId,
CancellationToken cancellationToken)
{
return await _dbContext.PlayerProfiles
.SingleOrDefaultAsync(
profile => profile.Id == playerId,
cancellationToken);
}

CA2016 is valuable because it identifies precisely this pattern. It is not proof that every token must always be forwarded: CancellationToken.None or default can be correct when you intentionally decide that a child operation must not be cancelled with its parent. But that choice should be rare, explicit, and defensible in code review.
This Microsoft Learn rule description explains how the .NET analyzer detects a break in cancellation propagation and shows the difference between forwarding a token and explicitly opting out.
Read “Cause” and “Rule description,” particularly the analyzer rationale. Then, under “How to fix violations,” follow “Example 1” from the reported call through its fix. Notice that passing default or CancellationToken.None is treated as an intentional opt-out, not as ordinary propagation.
Apply this to the game backend
For the next slice of the portfolio project, use this review checklist whenever a request initiates asynchronous work:
- Identify the owner. For an incoming API request, start with the
CancellationTokensupplied by ASP.NET Core. For a command or job you own, create and dispose oneCancellationTokenSourceat the outer boundary. - Make the token part of internal async contracts. Put it last and use a meaningful name such as
cancellationToken. - Forward the exact token. Preserve it through endpoint, service, repository, HTTP client, storage client, and any asynchronous stream operation.
- Use token-aware overloads. Check
HttpClient, EF Core,Task.Delay, stream APIs, and cloud SDK calls before writing manual polling logic. - Add explicit checkpoints only where needed. Use
ThrowIfCancellationRequested()before expensive CPU work, within substantial CPU loops, or before starting a side effect. - Do not hide cancellation. Catch
OperationCanceledExceptiononly at a boundary that has a meaningful cancellation policy. - Build with analyzers enabled. Treat CA2016 warnings as a prompt to inspect whether a broken cancellation path is intentional.
A useful manual verification technique is to place a controllable delay in a development-only catalog client, cancel its caller after a short period, and observe that the profile save is never reached. Then remove the token from the delayed operation and observe the difference: cancellation is requested, but the underlying work continues until its delay ends. That contrast makes the difference between possessing a token and honoring it concrete.
Key takeaways
CancellationTokenSourceowns the ability to request cancellation;CancellationTokencommunicates that request safely through the call chain.- Cancellation is cooperative. It works only when your code checks the token or passes it into an API that supports it.
- Propagate the caller’s token through every participating async method, always preferring token-aware I/O overloads.
- In ASP.NET Core, accept a
CancellationTokenin the endpoint and forward it; it represents the aborted-request signal. - Use
ThrowIfCancellationRequested()at meaningful CPU or side-effect boundaries, not as a substitute for passing tokens to I/O APIs. - Most cancellable APIs signal cancellation with
OperationCanceledException; do not disguise it as ordinary success or an unexpected server failure. - Cancellation does not roll back completed effects. Writes and external calls still require sound consistency and retry design.
- CA2016 helps reveal calls where a method receives a token but accidentally fails to forward it.
Next, you will build on this workflow discipline by implementing deterministic cleanup for synchronous and asynchronous resources with IDisposable and IAsyncDisposable.
Can't find a good explanation? Sign up and we'll make it for you
Sign up