← All topics/Concurrency

Practical interview questions

Scenario-style prompts with sample answer outlines. Focus is on how you would design and reason in real codebases.

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 prepareForReuse and guard itemID after every await.
  • Structured concurrency bubbles cancellation — prefer async let / task groups for screen-scoped work.
Cancel previous search task + debounce + generation guard
@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
            }
        }
    }
}
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

  • Race without cancellation: always pair cancel with an identity check after await for UI updates. Cancellation is best-effort.
  • withTaskCancellationHandler(operation:onCancel:) for bridging to legacy APIs that need an explicit teardown on cancel.