Skip to main content

📝 Lesson 3.1: Cancellation and Timeouts

A long-running operation the user no longer wants — or one that's taking too long — should stop. .NET has a unified, cooperative model for this built around CancellationToken, and it's the same mechanism for user cancellation and timeouts alike.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain .NET's cooperative cancellation model
  • Create and signal cancellation with CancellationTokenSource
  • Observe a CancellationToken in your own code
  • Implement timeouts with CancelAfter / WaitAsync
  • Combine multiple cancellation sources with linked tokens

Estimated Time: 60 minutes

Project: Make a long operation cancellable and add a timeout.

In This Lesson

Cooperative Cancellation

.NET does not forcibly kill running work — abruptly aborting a thread could leave data half-written and locks held, corrupting state. Instead it uses cooperative cancellation: cancellation is a request, and the running code chooses safe points to notice it and stop cleanly.

📖 Definition

Cooperative cancellation: a caller signals a request via a CancellationToken; the operation periodically checks the token and, when it sees a request, stops gracefully. Both sides cooperate — nothing is force-killed.

graph LR A["Caller: CancellationTokenSource"] -->|"Cancel()"| B["Token is signaled"] B --> C["Operation checks token"] C -->|"cancellation requested"| D["Stop cleanly
(throw OperationCanceledException)"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style D fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

Two types work together: a CancellationTokenSource (the caller holds it and triggers cancellation) and a CancellationToken (a lightweight handle passed into the operation, which it observes).

Token and Source

The caller creates a source, passes its Token to the operation, and calls Cancel() when it wants to stop:

using var cts = new CancellationTokenSource();

// Start the work, giving it the token
Task work = DoWorkAsync(cts.Token);

// Later — e.g. user pressed a button — request cancellation
cts.Cancel();

try
{
    await work;
}
catch (OperationCanceledException)
{
    Console.WriteLine("Work was cancelled.");
}

💡 Cancellation is one-way and one-shot

Once a CancellationTokenSource is cancelled, its token stays cancelled — you can't "un-cancel." Create a fresh source for a new operation. And always Dispose the source (the using above does this) since it may hold timer resources.

✅ It's the standard parameter

By convention, cancellable methods take a CancellationToken as their last parameter, often defaulting to default (i.e. CancellationToken.None). Nearly every async .NET API — HttpClient, file streams, EF Core — accepts one. Passing it through is how cancellation propagates down a call chain.

Observing the Token

An operation must actively check the token to be cancellable. There are two idioms.

Throw at a check point

ThrowIfCancellationRequested() throws an OperationCanceledException if cancellation was requested — the cleanest way to bail out of a loop:

async Task ProcessAsync(IEnumerable<string> items, CancellationToken token)
{
    foreach (string item in items)
    {
        token.ThrowIfCancellationRequested();   // stop point
        await ProcessOneAsync(item);
    }
}

Check the flag

Or inspect IsCancellationRequested to stop without throwing (e.g. to return partial results):

while (!token.IsCancellationRequested)
{
    DoOneStep();
}

Pass it to async calls

Most importantly, forward the token to the async operations you call. They'll honor it internally — cancelling a slow HTTP request or delay promptly, rather than waiting for the next manual check:

async Task<string> FetchAsync(string url, CancellationToken token)
{
    // The token cancels the wait/request itself
    await Task.Delay(5000, token);                       // cancellable delay
    return await _client.GetStringAsync(url, token);     // cancellable request
}

⚠️ A token you never check does nothing

Cancellation is cooperative — if your loop or method never observes the token (and never passes it to inner async calls), calling Cancel() has no effect and the work runs to completion. Sprinkle check points into long CPU loops, and always forward the token to I/O calls.

Handling Cancellation

When cancellation fires, the operation throws OperationCanceledException (its TaskCanceledException subclass, which you saw around timeouts in Intermediate, derives from it). Catch OperationCanceledException to cover both:

try
{
    await ProcessAsync(items, cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Cancelled — cleaning up partial work.");
}

💡 Cancellation is expected, not a failure

Treat OperationCanceledException as a normal control-flow signal, distinct from real errors. Catch it specifically (before a general catch) so you don't log a user-initiated cancel as an application fault. A common pattern: let it propagate, and handle it once at the top level where the operation was launched.

⚠️ Don't swallow the token in a catch-all

A broad catch (Exception) around cancellable code will also catch OperationCanceledException and can mask the fact that work was cancelled. Either re-check token.IsCancellationRequested, or catch OperationCanceledException first and rethrow/handle it deliberately.

Timeouts

A timeout is just cancellation on a timer — the same mechanism. A CancellationTokenSource can cancel itself automatically after a delay.

// Cancel automatically after 3 seconds
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));

try
{
    string result = await FetchAsync(url, cts.Token);
    Console.WriteLine(result);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Timed out after 3 seconds.");
}

You can also arm the timer after creating the source, or add a timeout to an existing task without threading a token through it, using WaitAsync (modern .NET):

// Arm later:
cts.CancelAfter(TimeSpan.FromSeconds(5));

// Add a timeout to any task:
try
{
    string result = await SomeTask.WaitAsync(TimeSpan.FromSeconds(2));
}
catch (TimeoutException)
{
    Console.WriteLine("The operation took too long.");
}

✅ One model, two triggers

User-initiated cancellation and timeouts are the same feature: a token that gets signaled — manually by Cancel(), or automatically by a timer. Your operation's code doesn't need to know which; it just observes the token and stops.

Linked Tokens

Sometimes an operation should stop if any of several conditions occurs — e.g. the user cancels or a timeout elapses or a parent operation is aborted. CreateLinkedTokenSource combines tokens: the linked token is cancelled when any of its sources is.

// One source for the user, one for a timeout
using var userCts = new CancellationTokenSource();
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

// Linked: cancels if the user cancels OR the 10s timeout fires
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
    userCts.Token, timeoutCts.Token);

await DoWorkAsync(linkedCts.Token);   // honors both triggers

💡 Registering a callback

You can run code the moment cancellation is requested with token.Register(callback) — handy for cleanup or to abort a resource that doesn't take a token directly. Dispose the registration when you're done to avoid leaks.

using var registration = token.Register(() => Console.WriteLine("Cancelling!"));

Exercise & Quiz

🏋️ Exercise: A Cancellable Worker with Timeout

Objective: Make a long operation observe a token, then add a timeout.

Instructions:

  1. Create a new project called Cancellation.
  2. Write async Task<int> CountSlowlyAsync(int to, CancellationToken token) that loops from 1 to to, does await Task.Delay(200, token) each step, prints the number, and returns the count reached.
  3. Start it counting to 100 with a CancellationTokenSource; from another task, Cancel() after ~1 second and observe it stops with OperationCanceledException.
  4. Add a timeout: use new CancellationTokenSource(TimeSpan.FromSeconds(2)) instead, and confirm the worker stops on its own after 2 seconds.

Starter Code:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));

try
{
    int reached = await CountSlowlyAsync(100, cts.Token);
    Console.WriteLine($"Finished at {reached}");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Stopped early (cancelled or timed out).");
}

static async Task<int> CountSlowlyAsync(int to, CancellationToken token)
{
    // TODO: loop 1..to, await Task.Delay(200, token), print each
    // return how far it got
    return 0;
}
💡 Hint

In the loop, await Task.Delay(200, token); will itself throw OperationCanceledException when the token fires — you don't even need a manual check for this one. Return i if the loop completes.

✅ Solution
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));

try
{
    int reached = await CountSlowlyAsync(100, cts.Token);
    Console.WriteLine($"Finished at {reached}");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Stopped early (cancelled or timed out).");
}

static async Task<int> CountSlowlyAsync(int to, CancellationToken token)
{
    int i = 0;
    for (i = 1; i <= to; i++)
    {
        token.ThrowIfCancellationRequested();   // optional explicit check
        await Task.Delay(200, token);           // cancellable wait
        Console.WriteLine(i);
    }
    return i - 1;
}

// Manual-cancel variant: start the task, then in another task Cancel() after 1s:
// _ = Task.Run(async () => { await Task.Delay(1000); cts.Cancel(); });

With the 2-second timeout, it prints about 1..10 and then "Stopped early" — the timer signalled the token, and Task.Delay observed it.

🎯 Quick Quiz

Question 1: What does "cooperative cancellation" mean?

Question 2: If your loop never checks the token or passes it to inner calls, what happens when the caller cancels?

Question 3: How is a timeout implemented in this model?

Summary

🎉 Key Takeaways

  • .NET uses cooperative cancellation: cancellation is a request the operation must observe and honor — nothing is force-killed.
  • The caller holds a CancellationTokenSource and calls Cancel(); the operation receives a CancellationToken (conventionally the last parameter).
  • Observe it with ThrowIfCancellationRequested() / IsCancellationRequested, and forward it to inner async calls. An unobserved token does nothing.
  • Cancellation surfaces as OperationCanceledException — treat it as expected control flow, not a failure.
  • Timeouts are cancellation on a timer (CancelAfter / new CTS(timespan) / WaitAsync); linked tokens combine multiple triggers.

📚 Additional Resources

🚀 What's Next?

You can start, await, and now stop async work. Next, we produce async data over time. In Lesson 3.2: Async Streams and Channels, you'll use IAsyncEnumerable and channels to stream results as they arrive.

🎉 You can stop the work!

Cancellation and timeouts make async operations controllable. Next: streaming async data.