← All topics/Concurrency

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. 1.Cancel: call searchTask?.cancel() so old work aborts as soon as it reaches a checkpoint.
  2. 2.Generation guard: increment a counter before the await and 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. 3.Debounce: open each new Task with Task.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.sleep plus cancel to cut request volume, and throttle when you need to cap the rate instead.
  • For cell reuse, cancel image loads in prepareForReuse and check the item identifier after every await.
  • Structured concurrency propagates cancellation to children, so prefer async let and task groups for screen-scoped work.
Cancel, debounce, and generation guard in one view model
@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
            }
        }
    }
}
Checkpoints inside the pipeline
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 a URLSessionTask created with a completion handler.