Home

Task.cancel() does almost nothing

I had a search screen that fired a request per keystroke, and I was cancelling the previous task before starting the next one. Requests still piled up, results still arrived out of order, and the screen still flickered between old and new results.

Task.cancel() was being called. It just was not doing what I thought.

Cancellation is a flag

cancel() sets a boolean on the task. That is the entire operation. It does not stop execution, it does not throw, it does not unwind the stack. A task that never checks the flag runs to completion exactly as if you had not cancelled it.

let task = Task {
    for item in hugeCollection {
        expensiveWork(item)          // never checks — runs all of it
    }
}
task.cancel()                        // sets a flag nobody reads

This is deliberate. Killing a thread mid-operation is how you corrupt state, and Swift’s designers chose cooperative cancellation over that. The cost is that cooperation is your job.

The two ways to check

Task.checkCancellation() throws CancellationError if cancelled:

for item in hugeCollection {
    try Task.checkCancellation()
    expensiveWork(item)
}

Task.isCancelled returns a Bool and lets you decide:

for item in hugeCollection {
    if Task.isCancelled {
        await saveProgress()          // clean up, then stop
        return
    }
    expensiveWork(item)
}

Use checkCancellation() when stopping is a simple throw. Use isCancelled when you need to finish something — flush a buffer, persist partial work, return what you have.

What already cooperates

Not everything needs a manual check. Most of the standard library’s async APIs are already cancellation-aware:

  • Task.sleep throws immediately on cancellation
  • URLSession’s async methods throw URLError.cancelled
  • Most AsyncSequence iterations stop
  • withTaskGroup cancels its children when the group is cancelled

So a task that is mostly awaiting these will stop reasonably promptly on its own. The problem is the code between the awaits — and any tight loop of synchronous work, which is where nothing checks unless you do.

Warning

Task.sleep throwing on cancellation is easy to get wrong with try?. Writing try? await Task.sleep(for: .seconds(1)) inside a polling loop swallows the cancellation error, and the loop keeps going forever. Let it propagate, or check isCancelled explicitly after it.

Structured concurrency cancels for you

The reason .task in SwiftUI works so well is that it ties a task’s lifetime to view identity:

.task(id: query) {
    results = await search(query)
}

When query changes, the previous task is cancelled and a new one starts. When the view goes away, the task is cancelled. No AnyCancellable, no manual bookkeeping, and it is correct by default.

The same applies inside async let and task groups — cancelling the parent cancels the children, all the way down.

Task { } created manually is the exception. It is unstructured: it does not inherit a parent’s cancellation, and nothing cancels it when the surrounding scope ends. If you create one, you own its lifetime.

final class SearchModel {
    private var searchTask: Task<Void, Never>?

    func search(_ query: String) {
        searchTask?.cancel()                 // cancel the previous
        searchTask = Task {
            guard !Task.isCancelled else { return }
            let results = await api.search(query)
            guard !Task.isCancelled else { return }   // check again after the await
            self.results = results
        }
    }
}

Two checks, not one. The first stops work that was cancelled before it began; the second stops a stale result being written after a newer search started. That second check was what my search screen was missing.

Cancellation is not an error

A cancelled task is not a failure, and treating it as one produces spurious error messages:

do {
    try await load()
} catch is CancellationError {
    return                                  // expected: say nothing
} catch {
    show(error)                             // real failures only
}

Without that first clause, navigating away from a screen mid-load shows the user an error alert about an operation they themselves cancelled. I have shipped that too.

Note that URLSession throws URLError.cancelled rather than CancellationError, so a network layer needs to handle both — or normalise one into the other at its boundary.

The checklist I use

  • Any loop doing real work gets a try Task.checkCancellation() at the top.
  • After every await in a task whose result mutates shared state, re-check isCancelled before writing.
  • CancellationError is caught and ignored at the UI boundary, separately from real errors.
  • Prefer .task(id:) over Task { } wherever the work belongs to a view.
  • If you create a Task manually, store it, and cancel it in deinit or when replacing it.

The general principle: cancellation is a request, not a command. The system asks; your code decides whether and when to stop. Nothing about that is automatic, and the failure mode is silent — the work simply continues, which looks exactly like the work being slow.