iOS

5 Swift Concurrency Cancellation Mistakes That Cause Stale UI

A cancelled Swift task can still return. This tested sample combines cooperative cancellation with a latest-request-wins check so stale work cannot replace current UI state.

Calling cancel() does not stop a Swift task. It records a cancellation request. The task and every function it calls must decide when to observe that request, how to unwind, and what the caller receives.

This matters when one request replaces another. Request A may still be running when request B starts. Cancelling A can save work, but cancellation alone does not stop A from returning just before it notices the request. The caller also needs an ordering rule that prevents A from replacing B’s state.

The synthetic sample uses record preparation to model the replacement flow. Its cancellation contract is all-or-nothing, and its search coordinator applies a latest-request-wins policy. Those two decisions address different failure modes.

Choose an explicit contract

The importer returns every prepared record, throws a domain error for invalid input, or throws CancellationError. It never returns partial output:

import Foundation

public enum ImportFailure: Error, Equatable {
    case emptyRecord(index: Int)
}

public struct RecordImporter: Sendable {
    public init() {}

    /// Cancellation is all-or-nothing: this function never returns partial output.
    public func prepare(_ records: [String]) async throws -> [String] {
        var prepared: [String] = []
        prepared.reserveCapacity(records.count)

        for (index, record) in records.enumerated() {
            try Task.checkCancellation()

            let value = record.trimmingCharacters(in: .whitespacesAndNewlines)
            guard !value.isEmpty else {
                throw ImportFailure.emptyRecord(index: index)
            }

            prepared.append(value.lowercased())

            if index.isMultiple(of: 32) {
                await Task.yield()
            }
        }

        try Task.checkCancellation()
        return prepared
    }
}

Task.checkCancellation() belongs at boundaries where abandoning the remaining work is safe. This importer checks before each record and once more before returning. The final check closes the gap between finishing the loop and publishing the result.

Mistake 1: cancelling the handle but ignoring the state

A synchronous loop does not stop merely because another scope called cancel(). If the loop has no suspension point and never reads the cancellation state, it can finish every record after cancellation was requested.

Place a cancellation check at a meaningful work boundary. For a large record import, that boundary may be each record or each batch. Checking every trivial instruction only adds noise; never checking allows obsolete work to continue.

Mistake 2: turning cancellation into an error banner

When a user replaces one search with another, cancellation is expected control flow. The UI boundary should handle CancellationError without changing the error presentation, then reserve its failure state for errors that the user can act on.

Do not hide cancellation inside the service by returning an empty array. The caller would lose the distinction between no matching records and a request that was superseded before it finished.

Mistake 3: letting an older task overwrite a newer result

Cancellation and result ordering are separate protections. An operation may finish just before it observes cancellation, or an underlying callback may ignore cancellation completely. The coordinator below cancels the previous task and rejects any result whose generation is no longer current:

Time   Request A                    Request B and coordinator
t0     starts, generation 1
t1     cancellation requested      starts, generation 2
t2     returns an old result       rejects generation 1
t3                                  accepts generation 2
The generation check prevents request A from publishing after request B has become current, even when A returns after receiving cancellation.
public actor SearchCoordinator {
    typealias Preparation = @Sendable ([String]) async throws -> [String]

    private var currentTask: Task<[String], Error>?
    private var generation = 0
    private let prepare: Preparation

    public init() {
        let importer = RecordImporter()
        prepare = { records in
            try await importer.prepare(records)
        }
    }

    init(prepare: @escaping Preparation) {
        self.prepare = prepare
    }

    public func replaceSearch(with records: [String]) async throws -> [String] {
        currentTask?.cancel()
        generation += 1
        let requestGeneration = generation

        let task = Task { try await prepare(records) }
        currentTask = task

        defer {
            if generation == requestGeneration {
                currentTask = nil
            }
        }

        let value = try await task.value
        guard generation == requestGeneration else {
            throw CancellationError()
        }
        return value
    }

    public func cancel() {
        generation += 1
        currentTask?.cancel()
        currentTask = nil
    }
}

The internal preparation closure is a test seam, not a second production policy. The public initializer still uses RecordImporter. Tests replace the closure with a controlled suspension so they can choose which request completes first without relying on scheduler timing.

Latest-request-wins is appropriate for this search example. A chat timeline, upload queue, or transaction processor may require ordered delivery instead. Name that policy in the coordinator rather than leaving completion order to decide the product behavior.

Mistake 4: using unstructured tasks without an ownership plan

A task created in a view model can outlive the screen unless something owns its handle and cancels it. A detached task also does not inherit the same task-local context, priority, or actor isolation as an ordinary structured child task.

Use async let or a task group when child work belongs to a parent operation. Store a task handle when work belongs to an object lifecycle, then cancel it when a new request replaces it or the owner is released. The sample coordinator owns one handle because it owns the replacement policy.

Mistake 5: installing a cancellation handler too late

withTaskCancellationHandler is useful when an async function wraps an operation with its own cancellation primitive. The handler needs to cover the complete suspended operation. Installing it after the callback has started leaves a race where cancellation can arrive before the handler is ready.

The cancellation action may run concurrently with the operation. Shared state must protect a cancel-before-start race and coordinate callback completion. It must also guarantee that the continuation resumes exactly once. A checked continuation can diagnose a double resume during development, but it cannot choose the correct state transitions for the bridge.

Test cancellation as public behavior

A deterministic test should control when each operation starts and completes. The sample’s PreparationGate suspends both searches until the test releases them. The first request therefore returns only after the second request has replaced it:

func testSearchCoordinatorRejectsAnOlderResult() async throws {
    let gate = PreparationGate()
    let coordinator = SearchCoordinator(prepare: gate.prepare)

    let first = Task {
        try await coordinator.replaceSearch(with: ["first"])
    }
    await gate.waitUntilStarted("first")

    let second = Task {
        try await coordinator.replaceSearch(with: ["second"])
    }
    await gate.waitUntilStarted("second")

    await gate.release("first")
    await assertCancellation(from: first)

    await gate.release("second")
    let latest = try await second.value
    XCTAssertEqual(latest, ["second"])
}

A second controlled test calls SearchCoordinator.cancel(), releases an underlying preparation closure that deliberately ignores cancellation, and verifies that the coordinator still rejects its result. The importer test also holds work behind an AsyncGate, cancels the task, and only then lets the importer run.

The September 18, 2026 verification run used Xcode 26.3, Swift 6.2.4, and macOS 15.8 on arm64. All four cancellation tests passed; all seven package tests passed.

The package tests prove the importer contract and coordinator ordering. They do not prove how a particular SwiftUI or UIKit screen owns its task. That lifecycle behavior still needs an integration test in the app that adopts the pattern.

The gates control ordering explicitly, so none of these tests uses a fixed sleep.

Review checklist

  • Does the API document what cancellation returns or throws?
  • Do CPU-heavy loops observe cancellation at safe boundaries?
  • Does an expected cancellation avoid user-facing failure UI?
  • Can an old result overwrite a newer request?
  • Who owns every unstructured task handle?
  • Does a callback bridge cancel its underlying operation and resume exactly once?
  • Are partial side effects committed only at an intentional boundary?

Primary sources