Question 4
Cancellation and stale async results
You have a screen that fires multiple async requests as the user types (such as 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 first fix is to store the Task handle and call cancel() on it before you start the next one.
cancel() only sets a flag. Nothing stops on its own. The task has to notice by checking Task.isCancelled, calling Task.checkCancellation(), or hitting a cancellation-aware await such as Task.sleep.
That leaves a window between calling cancel() and the task actually stopping. A result can still slip through and overwrite the UI before the flag is ever read, so cancellation alone isn't enough. Protect against stale results in three layers:
- 1.Cancel: call
searchTask?.cancel()so old work aborts as soon as it reaches a checkpoint. - 2.Generation guard: increment a counter before the
awaitand compare it when you resume. If it no longer matches, discard the result, which catches anything that slipped through before the flag was read. - 3.Debounce: open each new
TaskwithTask.sleep(for: .milliseconds(250)). Canceling during the sleep aborts the task before any request is made, so only the last keystroke fires.
To propagate cancellation deeper, add try Task.checkCancellation() before and after the network and decode steps. Inside a task group, canceling the parent cancels every child. The async URLSession APIs already honor cancellation, and for a legacy callback-based request you use withTaskCancellationHandler to call URLSessionTask.cancel().
Principles
cancel()only sets a flag and cancellation is best-effort, so always pair it with a generation guard.- Debounce with
Task.sleepplus cancel to cut request volume, and throttle when you need to cap the rate instead. - For cell reuse, cancel image loads in
prepareForReuseand check the item identifier after everyawait. - Structured concurrency propagates cancellation to children, so prefer
async letand 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 // bump the generation for this request
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)
// Re-check after the await
guard requestGen == generation, !Task.isCancelled else { return }
results = items
} catch is CancellationError {
// Canceled, nothing to do
} catch {
// Handle the error
}
}
}
}
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
- Canceling the previous task isn't enough on its own, because it may already be past its last checkpoint. The UI update still needs a generation check after the
await. withTaskCancellationHandler(operation:onCancel:)bridges cancellation to legacy APIs that need an explicit teardown call, such as aURLSessionTaskcreated with a completion handler.



