Chapter 4 — Cancellation and task lifetime
Cancellation is cooperative, not forceful
Cancelling a task does not stop it. It sets a flag. The task keeps running until it notices the flag and decides to stop.
flowchart LR
C["someone calls task.cancel()"] --> F["task.isCancelled becomes true"]
F --> N{"does the task
check the flag?"} N -->|"yes"| STOP["it stops cooperatively ✅"] N -->|"no"| RUN["it runs to completion anyway ⚠️"]
check the flag?"} N -->|"yes"| STOP["it stops cooperatively ✅"] N -->|"no"| RUN["it runs to completion anyway ⚠️"]
The three ways to respond to cancellation
for item in largeCollection {
if Task.isCancelled { return } // bail out, returning a partial/empty result
process(item)
}
for item in largeCollection {
try Task.checkCancellation() // throws CancellationError if cancelled
process(item)
}
The rule of thumb: cancellation is checked at
awaitpoints on cooperative APIs, and nowhere else automatically. A tight synchronous loop with noawaitwill never notice cancellation unless you add a check. Sprinkletry Task.checkCancellation()into long computations.
Cancellation propagates down the tree
flowchart TB
P["parent task
(cancelled)"] --> C1["child 1
(auto-cancelled)"] P --> C2["child 2
(auto-cancelled)"] C2 --> G["grandchild
(auto-cancelled)"]
(cancelled)"] --> C1["child 1
(auto-cancelled)"] P --> C2["child 2
(auto-cancelled)"] C2 --> G["grandchild
(auto-cancelled)"]
Building SearchNow
- Each keystroke should search — but a new keystroke makes the previous, in-flight search obsolete,
- We shouldn't fire a request on every keystroke; we debounce (wait for a small pause in typing),
// SearchNow/SearchViewModel.swift
import Foundation
import Observation
@MainActor
@Observable
final class SearchViewModel {
var query = "" { didSet { search() } }
private(set) var results: [SearchResult] = []
private let client = APIClient()
// The current in-flight search. Holding it lets us cancel the stale one.
private var searchTask: Task<Void, Never>?
private func search() {
// 1. Cancel the previous search — its results are now obsolete.
searchTask?.cancel()
let query = query.trimmingCharacters(in: .whitespaces)
guard !query.isEmpty else { results = []; return }
// 2. Start a new search task (inherits @MainActor, so `results = …` is safe).
searchTask = Task {
do {
// 3. Debounce: wait 300ms. If another keystroke arrives, THIS task is
// cancelled and sleep throws, so we never even hit the network.
try await Task.sleep(for: .milliseconds(300))
// 4. Do the request. URLSession throws if we were cancelled mid-flight.
let found = try await client.fetch([SearchResult].self, from: searchURL(query))
// 5. One last check before touching UI state.
guard !Task.isCancelled else { return }
results = found
} catch {
// CancellationError lands here and is simply ignored — expected.
}
}
}
}
- "c" arrives → search task A starts, sleeps 300ms.
- "a" arrives ~100ms later →
searchTask?.cancel()cancels A (itssleepthrows, so A never hits the - "t" arrives → cancels B, task C starts.
- The user pauses. C's 300ms sleep completes uninterrupted, C does one network request, and shows
withTaskCancellationHandler: react the instant it's cancelled
func trackedDownload() async throws -> Data {
let handle = LegacyDownloader()
return try await withTaskCancellationHandler {
try await handle.start() // the operation
} onCancel: {
handle.abort() // called IMMEDIATELY on cancel
}
}
Cancellation is not an error (usually)
} catch is CancellationError {
// expected — do nothing (or clean up quietly)
} catch {
// a REAL failure — surface this to the user
}
Tying task lifetime to UI lifetime
// Preview of Chapter 16: .task binds a task's lifetime to the view's.
.task(id: query) {
// runs when `query` changes; auto-cancelled when the view disappears
// OR when `query` changes again (which restarts it) — debounce + cancel for free
}
What we built in this chapter
- SearchNow, a debounced type-ahead search that fires one request for a burst of keystrokes by
- The core model: cancellation is cooperative —
cancel()sets a flag; the task must check it via - That cancellation propagates down the structured task tree automatically, but unstructured tasks
withTaskCancellationHandlerfor immediate teardown, with its concurrency caveats.- The idiom that
CancellationErroris expected flow, not a failure — catch it separately and don't
Mental model to take away
- Cancellation is a request, not a kill.
cancel()sets a flag; work continues until the task - You get cancellation "for free" at
awaitpoints on cooperative APIs; you must add - Structured concurrency cancels its whole subtree automatically — a major reason to prefer it over
- Cancel stale work (each new search cancels the last) and don't treat cancellation as an error.