Swift concurrency: what the docs don't tell you about task lifetimes
async/await looks simple until a Task outlives the screen that created it. Here's what I learned the hard way about cancellation, structured concurrency, and the lifetime bugs hiding behind innocent-looking Task blocks.
Swift’s async/await shipped in Swift 5.5 and the community adopted it fast. The happy path is genuinely elegant. The failure modes are subtle enough that I’ve now debugged the same class of bug three times in different codebases, mine included.
This is everything I wish I’d understood before reaching for Task { } the first time.
The Problem with Unstructured Tasks
When you write this in a SwiftUI view:
Button("Load Data") {
Task {
await viewModel.fetchUser()
}
}
You’ve created an unstructured task. It has no parent in the structured task tree. It does inherit priority, task-local values, and actor context from where it was created. Most importantly, it isn’t automatically cancelled when the view disappears.
Tap that button, navigate away before the request completes, and fetchUser() is still running. If it updates @Published properties, you’re mutating state for a view that no longer exists. If it holds a reference to the view model, you’re extending its lifetime beyond the view hierarchy’s knowledge. In the best case, you waste a network request. In the worst case, you create stale or out-of-order state updates.
The fix usually isn’t “never use Task { }.” It’s to decide who owns the work. If the work should follow the view’s lifetime, start it from a lifecycle API like .task or keep an explicit task handle you can cancel. If it should outlive the screen, move that responsibility to a longer-lived object and make that ownership explicit.
Structured Concurrency: The Right Mental Model
Swift Concurrency’s design intent is structured concurrency — tasks form a tree, and parent tasks can’t finish until all their children do. When a parent is cancelled, all children are cancelled. No orphans.
In SwiftUI, .task { } gives you that mental model for view-bound work:
.task {
await viewModel.fetchUser()
}
This task is automatically cancelled when the view disappears. No manual cancellation. No cancellables set. SwiftUI owns the task lifetime and ties it to the view’s lifecycle. If you need to start async work in response to something appearing, this is almost always the right choice.
The mental model: view-bound tasks have an owner. Unstructured tasks don’t. Be very deliberate about when you actually want work that can outlive the scope that created it.
When You Do Need Unstructured Tasks
Sometimes unstructured work is correct — for example, when a button action should survive navigation. “Send message” shouldn’t cancel if the user pops the compose screen. That’s intentional.
But the API needs care, and [weak self] isn’t a lifetime-management strategy by itself:
// ❌ Weak capture only skips the call if self is already gone.
// Once postMessage() starts, it will usually retain self for the duration.
Button("Send") {
Task { [weak self] in
await self?.postMessage()
}
}
// ✅ Keep a handle if this work should be cancellable
@State private var sendTask: Task<Void, Never>?
Button("Send") {
sendTask?.cancel()
sendTask = Task {
await viewModel.postMessage()
}
}
.onDisappear {
sendTask?.cancel()
}
The subtle point is that [weak self] only changes the capture at the moment you enter the task. If self still exists and postMessage() begins, the async instance method will typically retain self until it returns. If lifetime really matters, prefer explicit ownership: store the task handle and cancel it, or hand the work off to a longer-lived service that is supposed to outlive the view.
The Cancellation Contract
Here’s something the Getting Started guides don’t emphasize enough: cancellation is cooperative.
Calling .cancel() on a Task does not kill it mid-instruction. It marks the task as cancelled. From that point on, the task has to notice and decide to stop.
That’s why cancellation often feels inconsistent when you’re first learning Swift Concurrency. Some APIs stop quickly when cancelled. Others keep going. The difference is whether the code you’re awaiting cooperates with cancellation.
Many system APIs already do. URLSession, Task.sleep, and a lot of async sequences respond to cancellation for you. But your own long-running logic usually needs explicit checks:
func processItems(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation() // throws CancellationError if cancelled
await process(item)
}
}
Without Task.checkCancellation(), this loop keeps processing items even after someone has asked it to stop. For a short loop, that might not matter. For work that processes thousands of items, keeps a connection open, or burns battery in the background, it’s a real bug.
The practical rule:
- If your task does one cancellable system call, you’re often fine.
- If your task has a loop, multiple stages, or expensive work between
awaitpoints, check for cancellation yourself.
Cancellation is a contract, not an interrupt. The caller promises to signal “please stop.” Your task has to honor that signal.
The .task(id:) Pattern
The id parameter on .task, introduced in iOS 15, is one of the most useful parts of the API:
.task(id: viewModel.selectedTab) {
await viewModel.loadTabContent()
}
Every time selectedTab changes, SwiftUI cancels the current task and starts a new one. This is the cleanest way to re-trigger async work when inputs change: no onChange + Task gymnastics, no manual tracking of what’s in flight.
I replaced a Combine pipeline — $selectedTab.sink { ... } with a cancellable stored explicitly — with this one modifier. The semantics are cleaner and the lifetime is automatic.
Actor Reentrancy
One last gotcha. Actors protect access to their state, but they don’t guarantee that one async method runs straight through from top to bottom without interruption.
The moment an actor method hits await, it suspends and gives the actor a chance to run other work. That means state you checked before the await might be different by the time the method resumes. That’s actor reentrancy:
actor ProfileCache {
var profiles: [String: Profile] = [:]
func getProfile(id: String) async -> Profile {
if let cached = profiles[id] { return cached }
// 🚨 The method suspends here.
// While we're waiting, the actor can run another task.
let profile = await fetchFromAPI(id)
// By the time we resume, profiles[id] might no longer be nil.
profiles[id] = profile
return profile
}
}
Walk through one possible interleaving:
getProfile(id:)starts and sees there’s no cached value.- It calls
fetchFromAPI(id)and suspends atawait. - While that network call is in flight, the actor is free to handle another message.
- A second task can enter
getProfile(id:), fetch the same profile, and store it. - The first task resumes later and writes its result using an assumption that is now stale.
This isn’t a Swift bug. It’s the tradeoff that keeps actors practical: if actors stayed locked across every await, a slow network call could block the entire actor.
The practical rule is simple: after every await, assume actor state may have changed.
If your logic depends on a value still being the same after suspension, re-check it:
actor ProfileCache {
var profiles: [String: Profile] = [:]
func getProfile(id: String) async -> Profile {
if let cached = profiles[id] { return cached }
let fetched = await fetchFromAPI(id)
if let cached = profiles[id] { return cached }
profiles[id] = fetched
return fetched
}
}
The important mental model is: actors prevent simultaneous access to mutable state, but they do not preserve your method’s assumptions across suspension points.
Swift 6 Raises the Floor
Everything I’ve described above was the state of play under Swift 5’s more permissive concurrency checking. Swift 6 language mode, which shipped with Xcode 16, turns on strict concurrency checking. This means the compiler catches many of the mistakes I’ve been describing — sending non-Sendable types across isolation boundaries, accessing mutable state from multiple isolation domains, and some patterns of unsafe capture.
Xcode 16 doesn’t force that migration on you; new and existing projects can still stay in Swift 5 mode. But in practice, adopting strict concurrency is a productive compiler-guided refactoring exercise for any large codebase. Most of the warnings point to real problems — places where a reference type is passed between actors without realizing it, or where a closure captures mutable state that could race. The compiler often finds latent bugs that tests haven’t caught.
My recommendation: enable StrictConcurrency in your build settings now, even if you’re not on a full Swift 6 migration. The warnings are educational. Every one you fix makes your concurrency model more correct, and you’re building the habit of thinking in terms of isolation boundaries rather than hoping DispatchQueue labels are enough.
Swift Concurrency gives you the tools to write correct concurrent code. It doesn’t make incorrect concurrent code impossible. The structured parts of the API are safe by design. The unstructured parts — Task { }, detached, actor reentrancy — require the same discipline as any concurrent programming model. Swift 6’s strict checking raises the floor, but it doesn’t eliminate the need to understand the model.
Once that clicked, async/await stopped feeling like magic and started feeling like a precise tool.