Swift Cancellation Handling: Cancel Tasks Safely and Cleanly
Swift concurrency lets tasks run asynchronously, but those tasks also need a way to stop when the work is no longer needed. Cancellation handling is the part of Swift that helps you detect cancellation, exit early, and clean up resources without wasting time or producing stale results.
Quick answer: In Swift, cancellation is cooperative, not forced. Calling cancel() marks a task as cancelled, and your code must check Task.isCancelled or call Task.checkCancellation() and stop work itself.
Difficulty: Intermediate
You'll understand this better if you know: basic async/await syntax, how Swift tasks run, and the difference between synchronous and asynchronous work.
1. What Is Cancellation Handling?
Cancellation handling is the pattern of making asynchronous Swift code stop when it is no longer useful. A cancelled task does not automatically terminate all of its work at once; instead, Swift marks the task as cancelled and gives your code a chance to notice and respond.
- It prevents unnecessary network calls, file processing, and computation.
- It helps avoid showing outdated results after the user changes screens or input.
- It supports responsive apps by stopping background work early.
- It is cooperative: your task must check for cancellation points.
In Swift concurrency, this usually involves Task.cancel(), Task.isCancelled, and Task.checkCancellation(). Some APIs also throw CancellationError when they notice cancellation.
2. Why Cancellation Matters
Cancellation matters because async work often becomes irrelevant before it finishes. A search request may be replaced by a newer query, a download may no longer be needed, or a view may disappear before its task completes.
Without cancellation handling, your app can waste CPU, keep network connections open, and update state after the user has already moved on. That can create bugs that are hard to reproduce, especially in UI code and concurrent pipelines.
Cancellation also improves correctness. If a task should no longer deliver a result, stopping it early keeps your app from saving stale data or overwriting newer work.
3. Basic Syntax or Core Idea
The core idea is simple: one task requests cancellation, and the running task checks that request at safe points.
Minimal cancellation check
The most direct way to check cancellation is with Task.isCancelled. This returns true when the current task has been cancelled.
func performWork() async throws {
if Task.isCancelled {
return
}
// Continue doing work only if the task is still active.
}For throwing contexts, Task.checkCancellation() is often better because it throws CancellationError immediately when the task has been cancelled.
func loadData() async throws {
try Task.checkCancellation()
// Continue with the expensive work.
}Use cancel() from the outside to request cancellation.
let task = Task {
try await loadData()
}
task.cancel()This pattern does not forcibly kill the task. It only sets the cancellation flag, and your code decides when to stop.
4. Step-by-Step Examples
Example 1: Checking cancellation inside a loop
Long loops should check cancellation regularly so they can stop before doing unnecessary work. This matters for image processing, parsing, and other iterative tasks.
func processItems(_ items: [Int]) async throws {
for item in items {
try Task.checkCancellation()
print(item)
}
}This version stops promptly if the task is cancelled between iterations.
Example 2: Cancelling stale search work
When a user types a new query, you often want to cancel the old search task so it does not return outdated results.
var currentSearchTask: Task<Void, Never>?A new task replaces the old one, and the old one is cancelled before starting fresh work.
currentSearchTask?cancel()
currentSearchTask = Task {
do {
try await searchServer()
} catch is CancellationError {
return
} catch {
print("Search failed: \(error)")
}
}This keeps only the newest search alive and prevents older requests from winning the race.
Example 3: Cleaning up before returning
Cancellation often happens while a task holds temporary resources, such as file handles, network streams, or in-memory caches. You should release those resources as soon as cancellation is detected.
func downloadAndSave() async throws {
defer {
// Close files, release locks, or reset state here.
}
try Task.checkCancellation()
let data = try await fetchData()
try Task.checkCancellation()
try save(data)
}Checking before and after major steps helps prevent a task from doing work that no longer matters.
Example 4: Handling suspension points that may throw cancellation
Some async operations can notice cancellation automatically, especially when they are suspension points that throw. You still need to handle the thrown error properly.
func waitForResult() async throws {
do {
let value = try await fetchSlowValue()
print(value)
} catch is CancellationError {
print("Task was cancelled before the value arrived.")
throw CancellationError()
}
}This keeps cancellation distinct from ordinary failures such as network errors or decoding problems.
5. Practical Use Cases
- Canceling an in-flight search when the user types a new query.
- Stopping a download when the user leaves the screen.
- Ending a long parsing or encoding job when the result is no longer needed.
- Preventing repeated background syncs from stacking up.
- Aborting a task group when one child fails or the result is obsolete.
These use cases all share the same goal: do not keep spending resources on work that no longer matters.
6. Common Mistakes
Mistake 1: Assuming cancellation is automatic
New Swift developers often expect cancelled tasks to stop immediately without any extra checks. In Swift concurrency, cancellation is cooperative, so the task must observe the cancellation state.
Problem: This task keeps running because nothing checks whether it has been cancelled.
let task = Task {
for number in 1...100 {
print(number)
}
}Fix: Check for cancellation inside the work loop and exit when requested.
let task = Task {
for number in 1...100 {
if Task.isCancelled {
return
}
print(number)
}
}The corrected version works because the task gives itself a chance to stop.
Mistake 2: Catching and ignoring CancellationError
Cancellation should usually propagate unless you have a very specific reason to continue. Swallowing CancellationError can make a task appear successful when it was actually cancelled.
Problem: This code hides cancellation and continues as if the operation finished normally.
do {
try await loadProfile()
} catch {
print("Ignoring all errors")
}Fix: Handle cancellation separately and rethrow it or return early.
do {
try await loadProfile()
} catch is CancellationError {
return
} catch {
print("Profile load failed: \(error)")
}The corrected version preserves the meaning of cancellation and keeps real failures separate.
Mistake 3: Checking cancellation too late
If a task does expensive work before checking for cancellation, it can waste time after the result is already obsolete. This is common in data transformation or file processing code.
Problem: The task performs heavy work before noticing that it should stop.
func rebuildCache() async throws {
let data = try await fetchLargeDataSet()
let result = expensiveTransform(data)
try Task.checkCancellation()
try writeCache(result)
}Fix: Check before and after major work units so cancellation can stop the task earlier.
func rebuildCache() async throws {
try Task.checkCancellation()
let data = try await fetchLargeDataSet()
try Task.checkCancellation()
let result = expensiveTransform(data)
try Task.checkCancellation()
try writeCache(result)
}The corrected version avoids wasting work at multiple stages of the pipeline.
7. Best Practices
Practice 1: Check cancellation at natural boundaries
Cancellation checks work best before and after expensive operations such as network calls, parsing steps, or iteration batches. This keeps the code responsive without littering every line with checks.
func syncRecords() async throws {
try Task.checkCancellation()
let records = try await fetchRecords()
try Task.checkCancellation()
try saveRecords(records)
}This makes cancellation timely while keeping the code readable.
Practice 2: Treat cancellation differently from failure
Cancelled work is not the same as a network outage or a decoding bug. Handle those paths separately so logs, metrics, and UI behavior stay accurate.
do {
try await refreshFeed()
} catch is CancellationError {
return
} catch {
print("Refresh failed: \(error)")
}This separation makes it easier to debug real problems without treating cancellations as bugs.
Practice 3: Cancel obsolete tasks early
If new work replaces old work, cancel the old task immediately rather than waiting for it to finish. This is especially useful in search, autocomplete, and live preview features.
var currentTask: Task<Void, Never>?
func startLatestWork() {
currentTask?cancel()
currentTask = Task {
try? await refreshSuggestions()
}
}This avoids race conditions where an older task finishes after a newer one and overwrites the result.
8. Limitations and Edge Cases
- Cancellation does not interrupt synchronous CPU work automatically; you must add checks in the loop or algorithm.
- Non-throwing code often needs Task.isCancelled because there is no error to propagate.
- Some async APIs may complete even after cancellation if they do not observe the cancellation flag internally.
- A cancelled task can still run code until it reaches a cancellation check or suspension point that notices it.
- Task groups and child tasks follow structured cancellation rules, but they still rely on cooperative checks in the child work.
A task that is already cancelled may still perform cleanup in defer blocks or finally-style exit paths, which is often exactly what you want.
9. Practical Mini Project
Here is a small command-line style example that starts a task, cancels it quickly, and handles cancellation cleanly. This pattern mirrors how you would stop obsolete work in a real app.
import Foundation
func slowCount() async throws {
for number in 1...10 {
try Task.checkCancellation()
print("Count: \(number)")
try await Task.sleep(nanoseconds: 300_000_000)
}
}
let task = Task {
do {
try await slowCount()
} catch is CancellationError {
print("Counting was cancelled.")
} catch {
print("Unexpected error: \(error)")
}
}
Task {
try await Task.sleep(nanoseconds: 700_000_000)
task.cancel()
}This example shows a task doing work in small steps and stopping when cancellation is requested. The cancellation is observed inside slowCount(), and the outer task handles the CancellationError cleanly.
10. Key Points
- Swift cancellation is cooperative, not forced.
- Use cancel() to request cancellation from outside a task.
- Use Task.isCancelled in non-throwing code.
- Use Task.checkCancellation() in throwing code for immediate cancellation handling.
- Check cancellation at natural boundaries in loops and multi-step workflows.
- Do not swallow CancellationError unless you intentionally want to ignore cancellation.
11. Practice Exercise
- Write a function that simulates downloading five chunks of data.
- Check for cancellation before each chunk.
- Print a message when the task finishes normally.
- Cancel the task after the second chunk and verify that it stops early.
Expected output: The task should print only the first few chunks, then stop and report that it was cancelled.
Hint: Use Task.checkCancellation() inside the loop and wrap the call in a do/catch block.
import Foundation
func downloadChunks() async throws {
for chunk in 1...5 {
try Task.checkCancellation()
print("Downloading chunk \(chunk)")
try await Task.sleep(nanoseconds: 200_000_000)
}
print("Download completed")
}
let task = Task {
do {
try await downloadChunks()
} catch is CancellationError {
print("Download cancelled")
}
}
Task {
try await Task.sleep(nanoseconds: 450_000_000)
task.cancel()
}12. Final Summary
Swift cancellation handling is about writing async code that can stop when the work is no longer needed. The key idea is cooperative cancellation: another task requests cancellation, and your task checks for that request and exits at sensible points.
Use Task.isCancelled when you need a simple Boolean check, and use Task.checkCancellation() when your function can throw and should stop immediately. Separate cancellation from ordinary errors so your code stays predictable and your logs stay meaningful.
Once you start checking cancellation inside loops, before expensive steps, and around long-running operations, your Swift concurrency code becomes more responsive, more efficient, and easier to reason about. A good next step is to apply these patterns to task groups, search flows, or any async function that can be interrupted by newer work.