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 ⚠️"]

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 await points on cooperative APIs, and nowhere else automatically. A tight synchronous loop with no await will never notice cancellation unless you add a check. Sprinkle try 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)"]

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.
            }
        }
    }
}
  1. "c" arrives → search task A starts, sleeps 300ms.
  2. "a" arrives ~100ms later → searchTask?.cancel() cancels A (its sleep throws, so A never hits the
  3. "t" arrives → cancels B, task C starts.
  4. 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 cooperativecancel() sets a flag; the task must check it via
  • That cancellation propagates down the structured task tree automatically, but unstructured tasks
  • withTaskCancellationHandler for immediate teardown, with its concurrency caveats.
  • The idiom that CancellationError is 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 await points 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.