Cultivated Task Cancellation
I’ve been diving deeper into Swift’s structured concurrency (and strict Sendability conformance) lately, and I’ve learned quite a lot. I’m writing this down to deepen my understanding—and I hope others might find it useful as well. This is the first of a few posts revolving around these topics.
Why bother?
Some things can take quite a long time to compute, especially if they rely on outside resources. Swift’s tool for this is Task
. The following code creates a new “job” that runs heavy work in the background and returns a handle to that job as workTask
:
let workTask = Task { // Implicity Task<Value, Never>
return heavyWork()
}
At some point, we might wait for the task’s result to update our interface:
let result = await workTask.value
But if computation takes too long, we might no longer need the results. Think of a web browser: you start a Task
to load a site, but it's slooooow. When you close the window, you don't need the task or its result anymore. Wouldn't it be nice if you could stop this task to save some resources and valuable battery power and write genuinely not shitty software? 😉
The well-mannered retreat
The concept for ending tasks early is called cancellation. You can cancel any task from anywhere at any time, synchronously:
workTask.cancel()
But does this stop the task immediately? Well, it depends. Cancellation is cooperative. When you call cancel()
, you’re requesting the task to wrap things up. The task gets notified immediately but decides when it actually stops. You have the power to cancel from anywhere, anytime—but the task has the power to ignore you.
In fact, it’s up to the task whether it reacts at all. The simplest cancellation strategy is to have none—just ignore it. There are situations where that’s the only sensible thing to do. We’re talking concurrency—cancelling a task that has already finished is one example. Your task might also be calling legacy code that doesn’t support cancellation. Other things can’t always be interrupted, e.g. data stores may have consistency criteria that must not be violated. A cancellation is not a crash. I like to think of it as more of a well-mannered, orderly retreat.
Plus, the effect of a cancellation has to be specifically built into each task. Callers waiting on the value
of the workTask
in the example above always expect to get a Value
back. The task can’t just abort and return nothing.
Well, it could—if the Value
was an optional. If heavyWork
always returns a result, returning nil
could be a sensible indicator of cancellation. But if nil
is a common result of heavy work (I feel you!), we need something else. The task could return a partial result, or encode the cancellation in the result somehow, e.g. by returning an enumeration.
Or, it could throw an error. Remember how that task had a type of Task<Value, Never>
? The Never
could also be any Error
, indicating that the task can fail. Now heavyWork
might fail for other reasons too. In my experience, though, more often than not, we aren't concerned with why something failed. In those cases, it’s more “damn, we need to tell the user.” And so the task could also fail due to cancellation. Conveniently, there’s an error type built into Swift intended exactly for this: CancellationError
. You don’t have to use it, but there’s little reason not to.
Interestingly, as of Swift 6.2, Task
actually supports only two error types: Never
indicating no errors ever, or the wildcard any Error
. So if we throw errors, we can just throw any error we like.
So how do you tell if a task supports cancellation? That’s tricky to answer per-se because cancellation is voluntary behavior and needs to be represented in the task’s value or error type. If you’re lucky, cancellation support (or lack thereof) is documented.
It’s in the code!
We’ve talked about how tasks can support cancellation and how it must be part of their contract (i.e., value or error types). But how is the cancellation actually done? Where does it happen? Who handles it? And how?
Let’s look at the sample from above again. I’ll save you some scrolling by repeating it here:
let workTask = Task { // Implicity Task<Value, Never>
return heavyWork()
}
Nothing in this task is actually checking, aborting or throwing anything! We could cancel the task and nothing would happen. We could forcibly declare the Task throwing (just add <Value, any Error>
behind Task
)—but errors can’t come out of nowhere, not even in tasks. And so it turns out, it’s the task’s body or the functions it calls that need to handle cancellation. Somewhere, some code needs to be written or called that has support for it.
It’s a division of concerns, so to speak: The task provides a context (or scope) and serves as the cancellation target. The code running inside it handles the actual cancellation.
Similarly, there’s a division of capability: Whoever has the task’s handle (the task object) can cancel it—just call cancel()
. But without the handle that’s impossible.
While the task handle can be queried about its cancellation state, the handle itself is not directly available inside the task. It’s not impossible to access it, but not exactly easy or pretty. And, in fact, not needed.
The designated way to check for cancellation is to use static properties like Task.isCancelled
. Using static properties may seem odd at first, but it's clever: APIs called inside the task can check for cancellation without knowing where they're running or taking task handles as arguments. In fact, such APIs are probably the best way to handle cancellation, as we'll see shortly.
Any Swift code can check for cancellation at any time. If it’s inside a task, we’ll get that info. If not, the answer is “no, not cancelled.”
Two approaches
Enough background—let's make the sample cancellable. Until now, heavyWork
was just one monolithic operation. Let's split it into two parts, each still heavy, with the creative names heavyWork1
and heavyWork2
. For the sake of simplicity, I’m ignoring how these two interact. They might also just be independent things to do.
We need to model cancellation—either with an optional return value or by throwing an error. Let's look at both, optional return first:
let workTask = Task<Value?, Never> {
heavyWork1()
if Task.isCancelled {
return nil
}
return heavyWork2()
}
(We need to explicitly define an optional Value
here because the compiler needs to know the contextual type of the returned nil
.)
With an optional result type, the result has to be unwrapped:
if let result = await workTask.value {
// Use result
} else {
// Cancelled
}
And here’s the version that throws an error. Instead of using an if
and throwing the error ourselves, we can use the Task.checkCancellation
shorthand:
let workTask = Task { // Implicitly Task<Value, Error>
heavyWork1()
try Task.checkCancellation()
return heavyWork2()
}
This way, the result type stays the same, but the value
getter becomes throwing:
let result = try await workTask.value
In both cases, the consumer of the task’s result takes on the additional responsibility of dealing with potential cancellation. The error throwing variant could be considered more idiomatic, but in my opinion, both are valid approaches. As we’ll see, what’s fitting really depends on the situation.
The free lunch (that isn't)
You might be thinking, “okay, nice and all. But why go to all this effort to explain something that’s baked into the system anyway?” Well, I fear here comes an inconvenient truth. Not many things in the Standard Library and Foundation have cancellation support built in. In fact, as far as I know, it’s just three.
At least that's few enough to showcase them here briefly. 😅
First, Task
’s own sleep
function throws a cancellation error when cancelled. This may sound trivial (at least I thought so at first), but the idea for sleep
is to use it to build asynchronous timers:
let timerTask = Task {
try await Task.sleep(for: .seconds(3))
print("Time's up!")
}
The print statement will be reached only if the task survives three seconds uncanceled. Otherwise, the thrown error will end execution early.
Second, URLSession
, when called through an async
API, will automatically cancel all dependent network requests when the awaiting task has been cancelled:
let dataTask = Task {
return try await URLSession.shared.data(from: url)
}
The data
function must be throwing anyway when the request fails for other reasons. Consequently, cancellation support is pretty straightforward—it’s just another network error. 😉
Third, most AsyncSequence
s will break out of enumeration when the enclosing task is cancelled. But beware—AsyncSequence
is a protocol, and each implementation must add support individually. Luckily, the builtin AsyncStream
has it, as do sequences from system APIs like Notification Center:
let observerTask = Task {
let notifications = NotificationCenter.default.notifications(named: name)
for await next in notifications {
print(next)
}
}
The for await … in
construct stops iteration and ignores future values when cancelled. More precisely, it's the async iterator’s next()
function has support for it.
And that’s it. Correct me if I’m wrong, but those are all the cases supported out of the box. Any other cancellation is up to each individual project and package to design and implement. Either start using AsyncStream
where possible, or get your hands dirty.
What’s missing?
Apropos dirty hands… we're 1,500 words into task cancellation and not done yet. I have two more posts coming up on the dirtier part—cancellation handlers and what I call the cancellation trap. I’ll be sure to publish them shortly, but for now, you and I deserve a short break. 😉
Update 13.10.25: Thanks to Ole for pointing out that task handles can, in fact, be queried about cancellation state.
Update 14.10.25: Thanks to Friedrich for making the case that Task.sleep()
is more than a debugging helper.