I finally took task cancellation seriously after watching a search screen display the wrong result with perfectly valid network responses. I typed “sw,” paused briefly, and then completed the word “swift.” The second request finished first and the UI looked correct for a moment. Then the slower “sw” request returned and replaced the screen with older results.
Nothing had crashed. There was no obvious data race, and both responses were technically successful. The bug was simply that work which no longer mattered was still allowed to finish and commit its result.
My first fix was a request ID check before updating the UI. That prevented the stale commit, but the old request still consumed time and bandwidth. The better design was to cancel the previous task when a new query arrived, make the operation cooperate with cancellation, and still verify cancellation immediately before committing. This article walks through that version and the small command-line project I used to test it.
The Bug Looked Like a Networking Problem
The screen used a familiar search-as-you-type design. Every text change started an asynchronous request. Short queries often took longer because they returned more data, so response order was not guaranteed to match request order. During normal testing the network was fast enough that I rarely saw the problem. Adding an artificial delay made it repeatable.
I logged three events for each query: request started, response received, and UI committed. The useful log looked like this:
started: sw
started: swift
received: swift
committed: swift
received: sw
committed: sw
Once the sequence was visible, the problem stopped looking mysterious. The view model had no concept of a current request. It treated every successful response as relevant forever.
My First Attempt Only Hid the Result
I added a generation number. Each new query incremented the number, and a response could update the UI only if its captured generation still matched the current value. This is a valid last-request-wins technique, and I still use it when underlying work cannot be cancelled.
But Instruments showed that superseded requests continued running. On a screen where users typed quickly, several responses could be decoded even though only one would ever be shown. The generation check protected correctness at the commit point; it did not stop wasted work.
That distinction changed how I framed the problem. I needed two layers. Cancellation should tell stale work to stop as early as practical. A final identity or cancellation check should protect the UI if completion races with cancellation.
The Task Ownership Rule That Simplified the Model
I moved request ownership into the search model. It stores one current Task. Submitting a new query cancels the old task before installing a new one:
actor SearchModel {
private var currentTask: Task<Void, Never>?
private let api: SearchAPI
func submit(_ query: String) {
currentTask?.cancel()
currentTask = Task { [weak self, api] in
do {
let result = try await api.search(query)
try Task.checkCancellation()
await self?.commit(result)
} catch is CancellationError {
print("Cancelled stale request: \(query)")
} catch {
print("Request failed: \(error)")
}
}
}
}
The model now has an explicit rule: the stored task represents the only request currently allowed to affect visible search state. The old task may still exist briefly while cancellation propagates, but it no longer owns the screen.
I capture the API separately and use a weak reference to the model. That avoids making the task’s lifetime accidentally define the model’s lifetime. The correct ownership choice depends on the product, but it should be intentional rather than an incidental closure capture.
Calling cancel() Is Only a Request
This was the part I misunderstood at first. Task.cancel() does not terminate Swift code like killing a process. It marks the task as cancelled. The task and the APIs it awaits must cooperate.
Many Swift concurrency APIs already respond to cancellation. Task.sleep, for example, throws CancellationError. Other operations may finish normally, especially legacy callbacks or shared work. CPU-heavy loops need explicit checks:
for chunk in chunks {
try Task.checkCancellation()
process(chunk)
}
For nonthrowing code, Task.isCancelled can support a clean early return. I prefer Task.checkCancellation() in throwing operations because it keeps cancellation on the normal error path and makes missing checks easier to spot during review.
Why I Still Check Before the UI Commit
Even when the API supports cancellation, completion and cancellation can happen almost simultaneously. A response might already be available when the user types the next character. The final Task.checkCancellation() immediately before commit acts as the last gate.
If the model is an actor, remember that an await divides actor-isolated work into separate fragments. State can change while a method is suspended. Cancellation does not replace actor reentrancy reasoning. For more complex screens I use both cancellation and a request identifier, particularly when one task launches shared subtasks that should continue for other consumers.
The result commit itself is synchronous actor-isolated work. Keeping it small makes the transition easy to reason about: verify relevance, replace visible state, and notify the UI layer.
Cancellation Is Not the Same as Failure
My first version displayed every caught error in an alert. That meant quickly changing the search query produced an “operation cancelled” message—a terrible user experience for completely normal behavior.
I now handle CancellationError separately. It usually means the user moved on, the screen disappeared, or a newer task replaced the old one. Those events may deserve a debug log, but not an error banner. Real transport, decoding, and server failures still follow the screen’s ordinary error path.
This separation also improves metrics. If cancellation is counted as request failure, dashboards can suggest an outage when users are simply typing quickly or navigating away.
The Reproduction Project
The accompanying command-line project uses a FakeSearchAPI actor. Each request performs five delayed steps and checks cancellation between steps. The demo submits “sw,” waits long enough for the first request to begin, then submits “swift.”
The expected output shows the first request starting, cancellation being observed, the second request completing, and only “swift” being committed. Because the timing is controlled, I can rerun the sequence without depending on a real server.
This kind of fake has been more valuable to me than repeatedly throttling a simulator connection. It lets a test decide exactly when work suspends and resumes. For a production test suite I would also cover cancellation before the first suspension, cancellation just before completion, a real API error, and deallocation of the model while work is running.
Where I Apply This Pattern Now
Search is the easiest example, but the same ownership problem appears in image loading for reused cells, address autocomplete, live validation, document previews, and screens that reload when filters change. Whenever a newer action makes older work irrelevant, I ask which object owns the current task and who cancels it.
I do not automatically cancel shared operations. If several callers await the same token refresh or cached download, one caller moving on should not necessarily stop work needed by the others. In that case the shared service owns the operation, while each caller owns only its interest in the result.
The practical rule is simple: cancellation authority should follow task ownership. A view model can cancel work created solely for its current screen state. A shared cache or authentication service needs a policy that accounts for multiple waiters.
What I Check Before Shipping
- A new request cancels or invalidates the previous request.
- The underlying operation observes cancellation at useful boundaries.
- There is a final relevance check before visible state changes.
- Cancellation does not produce a user-facing failure message.
- Shared work is not cancelled by one consumer without an explicit policy.
- Tests control timing instead of hoping to reproduce a fast race.
- The task does not accidentally keep the screen model alive forever.
The biggest change in my code was not adding cancel(). It was making “which result is still allowed to matter?” part of the state model. Once that ownership is explicit, cancellation becomes a useful optimization and the final commit check preserves correctness.
Sources I Used to Verify the Behavior
Apple documents task cancellation in Task and the language-level concurrency model in The Swift Programming Language: Concurrency. The accompanying project uses controlled delays so the cancellation sequence can be reproduced locally with the Swift version installed on your Mac.
Download the Complete Runnable Project
The complete code for this article is available in its own GitHub repository: https://github.com/2252408699/swift-task-cancellation-demo.
Clone the repository and run swift run. The README contains the requirements and expected output.
