Cancellation is a policy, not a button
A new search replaces an old one. It is tempting to think that cancelling the old task is all the coordination we need:
searchTask?.cancel()
searchTask = Task {
results = try await searchFeature.search(for: query)
}But “please stop” and “this result may still update the screen” are different decisions. A dependency may finish after cancellation, or complete an external effect before it notices the request. We need an explicit rule for what the old operation is still allowed to publish.
First decide what kind of work this is
CFA distinguishes durable changes, replaceable queries, presentation-only work and long-lived observations. A search for yesterday’s text is replaceable. A save that the user has already requested normally needs to finish even if the editor closes. A screen-only preview may have no value once the screen disappears. An app-wide observer belongs to the app-scoped owner that starts and stops it.
These are product policies. Cancelling everything when a View disappears is not a substitute for choosing one.
Protect the publication boundary
This screen uses a latest-request-wins policy for search. Its ViewModel owns the current query’s presentation lifetime. SearchFeature returns matching values without publishing them into a global “current search” property; independent searches may execute independently. That is a deliberate Feature policy, not an accidental absence of coordination.
import Foundation
import Observation
@MainActor
@Observable
final class SearchViewModel {
private let searchFeature: SearchFeature
private var searchTask: Task<Void, Never>?
private var currentRequestID: UUID?
private(set) var results: [Item] = []
private(set) var searchError: String?
init(searchFeature: SearchFeature = AppModel.shared.searchFeature) {
self.searchFeature = searchFeature
}
@discardableResult
func search(for query: String) -> Task<Void, Never> {
searchTask?.cancel()
let requestID = UUID()
currentRequestID = requestID
searchError = nil
let task = Task { [weak self, searchFeature] in
defer {
if self?.currentRequestID == requestID {
self?.searchTask = nil
self?.currentRequestID = nil
}
}
do {
try Task.checkCancellation()
let matches = try await searchFeature.search(for: query)
try Task.checkCancellation()
guard self?.currentRequestID == requestID else { return }
self?.results = matches
} catch {
guard !Task.isCancelled,
!(error is CancellationError),
self?.currentRequestID == requestID else { return }
self?.searchError = "Could not search. Try again."
}
}
searchTask = task
return task
}
func cancelSearch() {
currentRequestID = nil
searchTask?.cancel()
searchTask = nil
}
}These results belong to this screen’s current search presentation; they are not a second authoritative copy of the application’s stored items. SearchFeature is the model entry point for the search capability. Any shared search policy or application state belongs there, while the provider performs the fetch. Returning values for an independent screen query does not require publishing one global current-search list.
There are two protections. Cancellation asks the obsolete work to stop. The request identity prevents an older task from changing current results, publishing an old error, or clearing the new task’s handle during cleanup.
The identity is justified here because tasks are replaced before their predecessors necessarily finish. It is not a requirement to add UUIDs to every retained task. All identity checks and state updates occur on MainActor, with no suspension between the final guard and the assignment.
The example intentionally preserves existing results while the new search runs or fails. Call cancelSearch() when the screen’s presentation policy says the work is no longer useful. Do not blindly copy that behaviour into a save operation.
Cancellation does not undo a save
Swift cancellation is cooperative: the task or an API it calls must respond to the cancellation request. Task.checkCancellation() reports cancellation by throwing. It does not reverse a database commit or retract a server request. Swift’s cancellation design explains the language mechanism.
For durable changes, define what “saved” means at the persistence boundary and report success only once that boundary confirms it. If a request’s final outcome is uncertain, reconcile it rather than telling the user it definitely failed—or retrying a non-idempotent command without checking.
One cancelled waiter is not the owner of everyone’s work
Suppose two screens await a shared download. One disappears. Automatically cancelling the download on behalf of that screen would also affect the remaining screen. The Feature, not an arbitrary waiter, owns that shared task.
A legitimate policy is to let the shared download finish, then have a cancelled caller discard its own result. Another is reference-counted interest with cancellation when no callers remain. The latter is more complicated; do not implement it unless the product needs it. The example above is a screen-specific search, not a universal implementation for shared requests.
Test the completion you did not expect
Start search A, then search B. Let B finish first. Make A’s dependency ignore cancellation and return afterwards. Only B may update the screen. Repeat with A throwing an error, and confirm it cannot replace B’s state or clear B’s handle. Test explicit cancellation and a later successful search too.
Documented CFA rationale: operation purpose determines lifetime, and obsolete work must not overwrite current state. Editorial trade-off: replacement tracking costs a little code, while durable work may consume resources after navigation. Both costs should buy a concrete behavioural guarantee.
Name the policy before changing the code
Ask who still needs the result, what must finish, and what an old completion is allowed to change. Then let the CFA AI Toolkit for iOS apply that reasoning:
Using CFA for iOS, tidy up cancellation in this code.
Scope: Swift 6, Observation and explicit MainActor isolation, for iOS 17+. SearchFeature and AppModel are composition dependencies, not implemented by this screen excerpt. Cancellation support depends on the underlying API. The example does not implement a shared-waiter cancellation registry or undo external effects. CFA rationale and editorial analysis are labelled separately.