Task.cancel() does almost nothing
I had a search screen that fired a request per keystroke, and I was cancelling the previous task before starting the next one. Requests still piled up, results still arrived out of order, and the screen still flickered between old and new results.
Task.cancel() was being called. It just was not doing what I thought.
Cancellation is a flag
cancel() sets a boolean on the task. That is the entire operation. It does not stop execution, it
does not throw, it does not unwind the stack. A task that never checks the flag runs to completion
exactly as if you had not cancelled it.
let task = Task {
for item in hugeCollection {
expensiveWork(item) // never checks — runs all of it
}
}
task.cancel() // sets a flag nobody reads
This is deliberate. Killing a thread mid-operation is how you corrupt state, and Swift’s designers chose cooperative cancellation over that. The cost is that cooperation is your job.
The two ways to check
Task.checkCancellation() throws CancellationError if cancelled:
for item in hugeCollection {
try Task.checkCancellation()
expensiveWork(item)
}
Task.isCancelled returns a Bool and lets you decide:
for item in hugeCollection {
if Task.isCancelled {
await saveProgress() // clean up, then stop
return
}
expensiveWork(item)
}
Use checkCancellation() when stopping is a simple throw. Use isCancelled when you need to
finish something — flush a buffer, persist partial work, return what you have.
What already cooperates
Not everything needs a manual check. Most of the standard library’s async APIs are already cancellation-aware:
Task.sleepthrows immediately on cancellationURLSession’s async methods throwURLError.cancelled- Most
AsyncSequenceiterations stop withTaskGroupcancels its children when the group is cancelled
So a task that is mostly awaiting these will stop reasonably promptly on its own. The problem is the code between the awaits — and any tight loop of synchronous work, which is where nothing checks unless you do.
Warning
Task.sleep throwing on cancellation is easy to get wrong with try?. Writing
try? await Task.sleep(for: .seconds(1)) inside a polling loop swallows the cancellation error,
and the loop keeps going forever. Let it propagate, or check isCancelled explicitly after it.
Structured concurrency cancels for you
The reason .task in SwiftUI works so well is that it ties a task’s lifetime to view identity:
.task(id: query) {
results = await search(query)
}
When query changes, the previous task is cancelled and a new one starts. When the view goes away,
the task is cancelled. No AnyCancellable, no manual bookkeeping, and it is correct by default.
The same applies inside async let and task groups — cancelling the parent cancels the children,
all the way down.
Task { } created manually is the exception. It is unstructured: it does not inherit a parent’s
cancellation, and nothing cancels it when the surrounding scope ends. If you create one, you own its
lifetime.
final class SearchModel {
private var searchTask: Task<Void, Never>?
func search(_ query: String) {
searchTask?.cancel() // cancel the previous
searchTask = Task {
guard !Task.isCancelled else { return }
let results = await api.search(query)
guard !Task.isCancelled else { return } // check again after the await
self.results = results
}
}
}
Two checks, not one. The first stops work that was cancelled before it began; the second stops a stale result being written after a newer search started. That second check was what my search screen was missing.
Cancellation is not an error
A cancelled task is not a failure, and treating it as one produces spurious error messages:
do {
try await load()
} catch is CancellationError {
return // expected: say nothing
} catch {
show(error) // real failures only
}
Without that first clause, navigating away from a screen mid-load shows the user an error alert about an operation they themselves cancelled. I have shipped that too.
Note that URLSession throws URLError.cancelled rather than CancellationError, so a network layer
needs to handle both — or normalise one into the other at its boundary.
The checklist I use
- Any loop doing real work gets a
try Task.checkCancellation()at the top. - After every
awaitin a task whose result mutates shared state, re-checkisCancelledbefore writing. CancellationErroris caught and ignored at the UI boundary, separately from real errors.- Prefer
.task(id:)overTask { }wherever the work belongs to a view. - If you create a
Taskmanually, store it, and cancel it indeinitor when replacing it.
The general principle: cancellation is a request, not a command. The system asks; your code decides whether and when to stop. Nothing about that is automatic, and the failure mode is silent — the work simply continues, which looks exactly like the work being slow.