Question 4
Cancellation and stale async results
You have a screen that fires multiple async requests as the user types (e.g. search). How do you ensure stale requests don't overwrite newer results?
Follow-ups
- How do you propagate cancellation through async chains?
Answer outline
Stale results happen when an older request finishes after a newer one and overwrites the UI. The primary fix is to store the Task handle and call cancel() on it before starting a new one.
task.cancel() only sets a flag — nothing stops automatically. Code must check Task.isCancelled, call Task.checkCancellation(), or hit a cancellation-aware await (e.g. Task.sleep) for cancellation to take effect. This means there's always a window between calling cancel() and the task actually stopping. A stale result can still slip through and overwrite the UI before the flag is ever read.
Think of stale-result protection as 3 layers:
(1) Cancel — call searchTask?.cancel() so old work aborts as soon as it hits a checkpoint.
(2) Generation guard — increment a counter before await and compare it when you resume; if it doesn't match, discard the result. This catches anything that slips through before the cancellation flag is read.
(3) Debounce — open each new Task with Task.sleep(for: .milliseconds(250)); cancelling during the sleep aborts the task before a network request is ever made, so only the last keystroke fires.
To propagate cancellation deeper, add try Task.checkCancellation() before and after network/decode steps. In withThrowingTaskGroup, parent cancellation automatically cancels children. For hard network teardown use withTaskCancellationHandler to call URLSessionTask.cancel() — but that bridges to the URL layer only; you still need to cancel the owning Task.
Principles
cancel()sets a flag — always pair it with a generation guard; cancellation is best-effort, not a guarantee.- Debounce (
Task.sleep+ cancel) reduces load. Throttle caps rate. - Cell reuse in lists: cancel image loads in
prepareForReuseand guarditemIDafter everyawait. - Structured concurrency bubbles cancellation — prefer
async let/ task groups for screen-scoped work.
@MainActor
final class SearchViewModel: ObservableObject {
@Published var results: [Item] = []
private var searchTask: Task<Void, Never>?
private var generation = 0
func search(_ query: String) {
searchTask?.cancel()
generation += 1 // increment the generation
let requestGen = generation
searchTask = Task {
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled else { return }
do {
let items = try await api.search(query)
// a check after the await
guard requestGen == generation, !Task.isCancelled else { return }
results = items
} catch is CancellationError {
} catch {
// handle
}
}
}
}
func fetchJSON<T: Decodable>(_ url: URL) async throws -> T {
try Task.checkCancellation()
let (data, _) = try await URLSession.shared.data(from: url)
try Task.checkCancellation()
return try JSONDecoder().decode(T.self, from: data)
}
Follow-up angles
- Race without cancellation: always pair cancel with an identity check after
awaitfor UI updates. Cancellation is best-effort. withTaskCancellationHandler(operation:onCancel:)for bridging to legacy APIs that need an explicit teardown on cancel.