Calling cancel() does not stop a Swift task. It records a cancellation request. The task and every function it calls must decide when to observe that request, how to unwind, and what the caller receives.
This matters when one request replaces another. Request A may still be running when request B starts. Cancelling A can save work, but cancellation alone does not stop A from returning just before it notices the request. The caller also needs an ordering rule that prevents A from replacing B’s state.
The synthetic sample uses record preparation to model the replacement flow. Its cancellation contract is all-or-nothing, and its search coordinator applies a latest-request-wins policy. Those two decisions address different failure modes.
Choose an explicit contract
The importer returns every prepared record, throws a domain error for invalid input, or throws CancellationError. It never returns partial output:
import Foundation
public enum ImportFailure: Error, Equatable {
case emptyRecord(index: Int)
}
public struct RecordImporter: Sendable {
public init() {}
/// Cancellation is all-or-nothing: this function never returns partial output.
public func prepare(_ records: [String]) async throws -> [String] {
var prepared: [String] = []
prepared.reserveCapacity(records.count)
for (index, record) in records.enumerated() {
try Task.checkCancellation()
let value = record.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else {
throw ImportFailure.emptyRecord(index: index)
}
prepared.append(value.lowercased())
if index.isMultiple(of: 32) {
await Task.yield()
}
}
try Task.checkCancellation()
return prepared
}
}Task.checkCancellation() belongs at boundaries where abandoning the remaining work is safe. This importer checks before each record and once more before returning. The final check closes the gap between finishing the loop and publishing the result.
Mistake 1: cancelling the handle but ignoring the state
A synchronous loop does not stop merely because another scope called cancel(). If the loop has no suspension point and never reads the cancellation state, it can finish every record after cancellation was requested.
Place a cancellation check at a meaningful work boundary. For a large record import, that boundary may be each record or each batch. Checking every trivial instruction only adds noise; never checking allows obsolete work to continue.
Mistake 2: turning cancellation into an error banner
When a user replaces one search with another, cancellation is expected control flow. The UI boundary should handle CancellationError without changing the error presentation, then reserve its failure state for errors that the user can act on.
Do not hide cancellation inside the service by returning an empty array. The caller would lose the distinction between no matching records and a request that was superseded before it finished.
Mistake 3: letting an older task overwrite a newer result
Cancellation and result ordering are separate protections. An operation may finish just before it observes cancellation, or an underlying callback may ignore cancellation completely. The coordinator below cancels the previous task and rejects any result whose generation is no longer current:
Time Request A Request B and coordinator
t0 starts, generation 1
t1 cancellation requested starts, generation 2
t2 returns an old result rejects generation 1
t3 accepts generation 2public actor SearchCoordinator {
typealias Preparation = @Sendable ([String]) async throws -> [String]
private var currentTask: Task<[String], Error>?
private var generation = 0
private let prepare: Preparation
public init() {
let importer = RecordImporter()
prepare = { records in
try await importer.prepare(records)
}
}
init(prepare: @escaping Preparation) {
self.prepare = prepare
}
public func replaceSearch(with records: [String]) async throws -> [String] {
currentTask?.cancel()
generation += 1
let requestGeneration = generation
let task = Task { try await prepare(records) }
currentTask = task
defer {
if generation == requestGeneration {
currentTask = nil
}
}
let value = try await task.value
guard generation == requestGeneration else {
throw CancellationError()
}
return value
}
public func cancel() {
generation += 1
currentTask?.cancel()
currentTask = nil
}
}The internal preparation closure is a test seam, not a second production policy. The public initializer still uses RecordImporter. Tests replace the closure with a controlled suspension so they can choose which request completes first without relying on scheduler timing.
Latest-request-wins is appropriate for this search example. A chat timeline, upload queue, or transaction processor may require ordered delivery instead. Name that policy in the coordinator rather than leaving completion order to decide the product behavior.
Mistake 4: using unstructured tasks without an ownership plan
A task created in a view model can outlive the screen unless something owns its handle and cancels it. A detached task also does not inherit the same task-local context, priority, or actor isolation as an ordinary structured child task.
Use async let or a task group when child work belongs to a parent operation. Store a task handle when work belongs to an object lifecycle, then cancel it when a new request replaces it or the owner is released. The sample coordinator owns one handle because it owns the replacement policy.
Mistake 5: installing a cancellation handler too late
withTaskCancellationHandler is useful when an async function wraps an operation with its own cancellation primitive. The handler needs to cover the complete suspended operation. Installing it after the callback has started leaves a race where cancellation can arrive before the handler is ready.
The cancellation action may run concurrently with the operation. Shared state must protect a cancel-before-start race and coordinate callback completion. It must also guarantee that the continuation resumes exactly once. A checked continuation can diagnose a double resume during development, but it cannot choose the correct state transitions for the bridge.
Test cancellation as public behavior
A deterministic test should control when each operation starts and completes. The sample’s PreparationGate suspends both searches until the test releases them. The first request therefore returns only after the second request has replaced it:
func testSearchCoordinatorRejectsAnOlderResult() async throws {
let gate = PreparationGate()
let coordinator = SearchCoordinator(prepare: gate.prepare)
let first = Task {
try await coordinator.replaceSearch(with: ["first"])
}
await gate.waitUntilStarted("first")
let second = Task {
try await coordinator.replaceSearch(with: ["second"])
}
await gate.waitUntilStarted("second")
await gate.release("first")
await assertCancellation(from: first)
await gate.release("second")
let latest = try await second.value
XCTAssertEqual(latest, ["second"])
}A second controlled test calls SearchCoordinator.cancel(), releases an underlying preparation closure that deliberately ignores cancellation, and verifies that the coordinator still rejects its result. The importer test also holds work behind an AsyncGate, cancels the task, and only then lets the importer run.
The September 18, 2026 verification run used Xcode 26.3, Swift 6.2.4, and macOS 15.8 on arm64. All four cancellation tests passed; all seven package tests passed.
The package tests prove the importer contract and coordinator ordering. They do not prove how a particular SwiftUI or UIKit screen owns its task. That lifecycle behavior still needs an integration test in the app that adopts the pattern.
The gates control ordering explicitly, so none of these tests uses a fixed sleep.
Review checklist
- Does the API document what cancellation returns or throws?
- Do CPU-heavy loops observe cancellation at safe boundaries?
- Does an expected cancellation avoid user-facing failure UI?
- Can an old result overwrite a newer request?
- Who owns every unstructured task handle?
- Does a callback bridge cancel its underlying operation and resume exactly once?
- Are partial side effects committed only at an intentional boundary?