What Is Task Cancellation in Swift?
Task cancellation is Swift’s cooperative mechanism for telling asynchronous work that its result is no longer needed. Cancelling a task marks it as cancelled; the task and the functions it calls must notice that state and stop appropriately.
let refreshTask = Task {
try await launchManager.refresh()
}
refreshTask.cancel()
The call to cancel() sends a cancellation signal. It does not forcibly terminate the task at that exact line, kill a thread or erase the work already performed.
The central idea: cancellation changes what a task should do next. The task must cooperate by observing cancellation and finishing promptly.
The Real Problem Is Obsolete Work
Imagine that the user opens the Rocket Launch app and pulls to refresh. Before the first request finishes, they refresh again.
Refresh A ─────────────── returns old result
Refresh B ─── returns new result
If refresh B finishes first, the feature displays the newest launches. Refresh A can then finish later and overwrite that state with an older response.
Task {
let launches = try await manager.fetchLaunches()
self.launches = launches
}
The code is free from a low-level data race when it runs on MainActor, but it still has a logical race. Two valid operations complete in an order that no longer matches the user’s intent.
Cancellation lets the feature declare that refresh A became obsolete when refresh B began.
Cancellation Is a State, Not an Interruption
A task has cancellation state that can be queried while the task is running.
if Task.isCancelled {
return
}
Once marked cancelled, the task remains cancelled. The runtime does not jump into arbitrary synchronous code and force it to return.
Running task
│
│ cancel() called
▼
Task marked cancelled
│
│ task reaches a check or cancellation-aware await
▼
Task responds and finishes
This cooperative design gives each operation control over safe cleanup and the meaning of a partial result.
Cooperative cancellation means cancellation is requested by setting task state, while the running operation remains responsible for observing and responding to that request.
Task.isCancelled Lets Code Choose a Normal Return
Use Task.isCancelled when cancellation should produce a normal result or an early return.
func normalise(
_ launches: [Launch]
) -> [Launch] {
var result: [Launch] = []
for launch in launches {
if Task.isCancelled {
return result
}
result.append(launch.normalised())
}
return result
}
This function chooses to return the successfully processed values. That can be appropriate for best-effort work, but it also hides the distinction between a complete result and a cancelled partial result.
The response to cancellation is an API design decision. Returning an empty array, a partial array or no value can each be correct in a different operation.
Task.checkCancellation Throws
Use Task.checkCancellation() when cancellation should stop the current operation by throwing CancellationError.
func normalise(
_ launches: [Launch]
) throws -> [Launch] {
var result: [Launch] = []
for launch in launches {
try Task.checkCancellation()
result.append(launch.normalised())
}
return result
}
The thrown error travels through ordinary Swift error handling. Callers cannot mistake the partial array for a completed answer.
do {
let launches = try normalise(rawLaunches)
await store.replace(with: launches)
} catch is CancellationError {
// The result became obsolete. Do not publish it.
}
Many Suspending APIs Already Check Cancellation
Some Swift concurrency APIs respond to cancellation while suspended. Task.sleep, for example, throws when its task is cancelled.
func searchLaunches(
matching query: String
) async throws -> [Launch] {
try await Task.sleep(for: .milliseconds(300))
return try await launchAPI.search(query)
}
The sleep creates a short debounce period. If the user types another character and the current search task is cancelled, the sleep can throw instead of waiting for the full delay.
Do not assume that every asynchronous function checks cancellation. An API must document or implement how it responds. Cancellation state automatically propagating into a task is not the same as every underlying operation stopping automatically.
Check Before Publishing a Result
An operation can become cancelled while an awaited API is finishing. A final check protects the boundary where background work becomes visible feature state.
func loadLaunches() async throws -> [Launch] {
let launches = try await launchAPI.fetchUpcomingLaunches()
try Task.checkCancellation()
return launches
}
The check says that a technically successful network response is not a valid result if the owning task no longer needs it.
The most important cancellation check is often the one immediately before obsolete work would update current state.
A Feature Can Own Its Refresh Task
An unstructured task needs an explicit owner. A main-actor feature can retain its current refresh task and cancel it before starting another.
@MainActor
final class LaunchFeature: ObservableObject {
@Published private(set) var launches: [Launch] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let manager: LaunchManager
private var refreshTask: Task<Void, Never>?
private var activeRefreshID: UUID?
init(manager: LaunchManager) {
self.manager = manager
}
func refresh() {
refreshTask?.cancel()
let refreshID = UUID()
activeRefreshID = refreshID
isLoading = true
refreshTask = Task {
do {
let loaded = try await manager.fetchLaunches()
try Task.checkCancellation()
guard activeRefreshID == refreshID else { return }
launches = loaded
errorMessage = nil
} catch is CancellationError {
// A newer refresh owns the screen now.
} catch {
if activeRefreshID == refreshID {
errorMessage = "Could not load launches."
}
}
if activeRefreshID == refreshID {
isLoading = false
}
}
}
}
The task belongs to LaunchFeature, not to the short call of refresh(). Starting a newer refresh cancels the older task. The final check prevents the older task from publishing after cancellation.
Cancellation is treated as expected control flow rather than a user-facing network failure.
Search Is the Clearest Cancellation Example
Every new character can make the previous search query obsolete.
@MainActor
final class LaunchSearchFeature: ObservableObject {
@Published private(set) var results: [Launch] = []
private var searchTask: Task<Void, Never>?
private let api: LaunchAPI
func search(for query: String) {
searchTask?.cancel()
guard !query.isEmpty else {
results = []
return
}
searchTask = Task {
do {
try await Task.sleep(for: .milliseconds(300))
let matches = try await api.search(query)
try Task.checkCancellation()
results = matches
} catch is CancellationError {
// A newer query replaced this one.
} catch {
guard !Task.isCancelled else { return }
results = []
}
}
}
}
If the user types art, then quickly changes it to artemis, the task for art is no longer allowed to update the result list.
"art" task ───── cancelled ──X
"artemis" task ─────────────▶ displayed results
Navigation Can Cancel Feature Work
When the user leaves a launch-details screen, its unfinished request may no longer have an owner.
@MainActor
func didLeaveScreen() {
detailsTask?.cancel()
detailsTask = nil
}
The task still needs to cooperate, but the feature has made its lifecycle decision explicit. Work started for one screen should not continue updating that screen after the user has moved elsewhere.
Framework-managed task scopes can also connect cancellation to view lifetime. The architectural principle remains the same: the lifecycle that owns the result should own cancellation.
Cancellation Propagates Down Structured Task Trees
When a parent task is cancelled, its async let and task-group children are marked cancelled too.
Cancelled refresh task
├─ SpaceX child marked cancelled
├─ NASA child marked cancelled
└─ ESA child marked cancelled
Each child still decides how quickly it stops. A provider waiting in a cancellation-aware operation may throw promptly. A long synchronous decoding loop must check cancellation itself.
for try await providerResult in group {
try Task.checkCancellation()
launches += providerResult
}
The group does not abandon its children. Its structured scope waits for every child to finish responding before it exits.
Child Cancellation Does Not Automatically Cancel Its Parent
Cancellation normally propagates down the task tree, from parent to children. A child deciding to finish early does not automatically mark its parent or siblings cancelled.
A parent can choose a broader policy. For example, after receiving the first acceptable provider result, it can call group.cancelAll() to tell the remaining children that their values are no longer required.
if let firstResult = try await group.next() {
group.cancelAll()
return firstResult
}
The parent remains responsible for defining what one child’s completion or failure means for the overall operation.
Cancellation Does Not Guarantee Network Abortion
Cancelling the Swift task may cause a cancellation-aware networking API to cancel its underlying request. That behaviour belongs to the API implementation; it is not guaranteed merely because a task has been marked cancelled.
A wrapper around a callback-based or custom network client may need to connect Swift cancellation to the underlying request explicitly.
func fetchData(
using request: NetworkRequest
) async throws -> Data {
try await withTaskCancellationHandler {
try await request.value()
} onCancel: {
request.cancel()
}
}
This conceptual wrapper assumes that NetworkRequest is safe to share and that cancel() can be called synchronously from the handler.
Cancellation Handlers Connect External Resources
withTaskCancellationHandler registers an onCancel closure around an operation.
try await withTaskCancellationHandler {
try await operation.run()
} onCancel: {
operation.requestStop()
}
The handler runs when cancellation is requested, including when the task was already cancelled before entering the handler’s scope. It does not replace cooperative checks inside the operation.
The cancellation handler is synchronous and may run concurrently with the operation. It should perform small, thread-safe notification or cleanup work. It should not directly mutate ordinary task-local or actor-isolated state without the correct protection.
Cancellation and Errors Have Different Meanings
A failed provider and a cancelled refresh are not the same event.
- A provider error means the requested operation was still wanted but could not complete.
- Cancellation means the operation’s result is no longer wanted, or its owner is ending.
do {
launches = try await manager.fetchLaunches()
} catch is CancellationError {
return
} catch {
errorMessage = "Could not load launches."
}
Treating cancellation as an ordinary failure can flash unnecessary error messages whenever a user types, refreshes again or leaves a screen.
defer Still Performs Local Cleanup
Cancellation often exits through a thrown error, so ordinary Swift cleanup remains useful.
func refresh() async throws {
isLoading = true
defer { isLoading = false }
launches = try await manager.fetchLaunches()
}
The defer block runs whether loading succeeds, fails or throws because cancellation was detected. Use cancellation handlers to notify external concurrent resources; use defer for predictable lexical cleanup owned by the function.
The Complete Mental Model
Owner no longer needs result
│
▼
task.cancel()
│
▼
Task marked cancelled; handlers notified
│
▼
Task reaches check or aware suspension point
│
┌────┴────┐
▼ ▼
return throw CancellationError
│ │
└────┬────┘
▼
Cleanup runs; obsolete result is not published
Cancellation state can flow down a structured task tree, but each operation must cooperate. The owner initiates cancellation, the task detects it, lower-level resources receive it when necessary and the feature refuses stale results.
What to Remember
- Cancellation is a cooperative signal, not forced task termination.
cancel()marks a task as cancelled and runs registered cancellation handlers.Task.isCancelledsupports a normal early return.Task.checkCancellation()throwsCancellationError.- Some suspending APIs detect cancellation, but not every asynchronous API does.
- Check cancellation before publishing a result that may have become obsolete.
- The feature lifecycle that owns a task’s result should own its cancellation.
- Structured cancellation propagates from parents to children and remains cooperative.
- Cancelling one child does not automatically cancel its parent or siblings.
- Task cancellation does not inherently guarantee cancellation of an underlying network request.
- Cancellation handlers bridge cancellation to thread-safe external resources.
- Cancellation is expected control flow and should not automatically become a user-facing error.
Frequently Asked Questions
Does task.cancel() stop a task immediately?
No. It marks the task as cancelled. The task stops when its code or a called API observes cancellation and responds.
Does cancellation kill the task’s thread?
No. Tasks do not permanently own threads, and cancellation does not terminate a system thread.
What is the difference between isCancelled and checkCancellation()?
isCancelled returns a Boolean so the operation can choose its response. checkCancellation() throws CancellationError when the current task is cancelled.
Will await automatically throw when a task is cancelled?
No. Only cancellation-aware throwing operations do so. await by itself is merely a potential suspension marker.
Should cancellation display an error?
Usually not when cancellation represents normal user behaviour such as replacing a search, refreshing again or leaving a screen.
Where should I check cancellation?
Check inside substantial synchronous loops, after important suspension points and immediately before publishing a result whose relevance may have changed.
References
- The Swift Programming Language: Concurrency
- Apple Developer Documentation: Task.cancel()
- Apple Developer Documentation: withTaskCancellationHandler
- Swift Evolution SE-0304: Structured Concurrency
Continue Learning
The refresh and search examples retain a Task whose lifetime extends beyond the synchronous method that created it. The next article, What Is an Unstructured Task in Swift?, will explain who owns that task, which context it inherits and how to manage its handle, result and cancellation without losing the guarantees our architecture now depends on.
Download the Xcode Playground
Use the accompanying playground to start two overlapping launch refreshes and observe the stale result overwrite the newer one. Retain and cancel the first task, add cancellation checks before state publication, then implement a debounced launch search and a custom request bridged through a cancellation handler.
