How does task cancellation work in Swift concurrency?
TL;DR
Cancelling a task does not stop it right away. It only sets a flag.
Your code must check the flag: Task.isCancelled or try Task.checkCancellation(). Many APIs like Task.sleep and URLSession check it for you.
Cancellation flows down to child tasks (async let, task groups). It does not reach unstructured Task { } you created inside.
Cancelling a task
let task = Task {
try await loadFeed()
}
// later, e.g. when the user leaves the screen
task.cancel()Checking for cancellation
Task.isCancelled — check and decide
func processAll(_ items: [Item]) async -> [Result] {
var results: [Result] = []
for item in items {
if Task.isCancelled { break } // stop early, return partial results
results.append(await process(item))
}
return results
}try Task.checkCancellation() — throw
func importPhotos(_ photos: [Photo]) async throws {
for photo in photos {
try Task.checkCancellation() // throws CancellationError
try await upload(photo)
}
}Use this when cancellation should end the whole operation as an error.
APIs that check for you
try await Task.sleep(for: .seconds(2)) // throws CancellationError
let (data, _) = try await URLSession.shared.data(from: url) // throws URLError.cancelledLong CPU loops do not check. You must add checks yourself.
Structured tasks get cancelled automatically
func loadScreen() async throws {
async let profile = fetchProfile()
async let feed = fetchFeed()
_ = try await (profile, feed)
}
let t = Task { try await loadScreen() }
t.cancel() // profile and feed child tasks are cancelled tooSame for task groups. If one child throws in a throwing group and you rethrow, the other children are cancelled.
Unstructured tasks do not inherit cancellation
let parent = Task {
Task {
// this inner task is NOT cancelled when parent is cancelled
await syncEverything()
}
}
parent.cancel()Task { } and Task.detached { } are independent. Keep a handle and cancel them yourself.
Cancellation handlers
For APIs that are not async-aware, react to cancellation immediately:
func download(_ url: URL) async throws -> Data {
let loader = LegacyLoader()
return try await withTaskCancellationHandler {
try await loader.start(url)
} onCancel: {
loader.stop() // runs right away, on any thread
}
}The onCancel closure can run at the same time as the operation. It must be thread-safe.
SwiftUI does it for you
struct SearchView: View {
@State private var query = ""
@State private var results: [String] = []
var body: some View {
List(results, id: \.self) { Text($0) }
.searchable(text: $query)
.task(id: query) {
try? await Task.sleep(for: .milliseconds(300)) // debounce
guard !Task.isCancelled else { return }
results = await search(query)
}
}
}.task cancels its task when the view disappears. With id:, it cancels and restarts whenever query changes. This gives a simple debounce.
UIKit: cancel on disappear
final class FeedViewController: UIViewController {
private var loadTask: Task<Void, Never>?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadTask = Task { await viewModel.load() }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
loadTask?.cancel()
}
}Common mistakes
- Thinking
cancel()kills the task. It does not. - Catching
CancellationErrorand showing an error alert. Cancellation is usually not an error for the user. - Writing
try? await Task.sleep(...)and then continuing.try?swallows the cancellation. CheckTask.isCancelledafter it.
Common follow-up questions
- Can a cancelled task still return a value? Yes. It can return partial results or a default. Cancellation is just a request.
- Can you un-cancel a task? No. Once cancelled, it stays cancelled.
- Does cancelling a task release its memory? Only after the task actually finishes running.
- Does a cancelled
Task.sleepwait the full time? No. It wakes up right away and throwsCancellationError. - How do you test cancellation? Start a task, cancel it, then
await task.valueortask.resultand check you gotCancellationErroror partial results.