Understanding task cancellation and lifetimes in Swift concurrency
Learn how structured and unstructured tasks behave in Swift's concurrency system.
27 Oct 2025 · 6 min read
Swift's concurrency system gives us different ways to create and manage asynchronous work: some tasks are automatically tied to a scope, while others run independently. This distinction determines how long a task lives, whether it's automatically cancelled, and who is responsible for cleaning it up.
In this article, we'll look at how cancellation works in structured concurrency, what changes when we move to unstructured or detached tasks, and how to manage task lifetimes safely.

Structured tasks
In Swift, structured concurrency ties the lifetime of async work to the scope where it was created.
When we, for example, use async let, withTaskGroup, or SwiftUI's .task modifier, the system automatically cancels work when that scope ends.
For example, a Task started by SwiftUI through .task { ... } is automatically cancelled when the view disappears:
struct ContentView: View {@State private var text = ""var body: some View {Text(text).task {text = await loadTitle()}}}
When the view leaves the hierarchy, SwiftUI cancels the task.
The same applies to async let or withTaskGroup inside an async function:
func load() async throws -> [String] {async let a = loadUser()async let b = loadPosts()async let c = loadSettings()return try await [a, b, c]}
Here, all three child tasks are bound to the scope of load(). When the load() function finishes, either successfully or with an error, or gets cancelled, all three async child tasks are cancelled as well.
This automatic cleanup is what makes structured concurrency predictable.
Unstructured and detached tasks
When we manually create a task using Task { ... } outside of a structured scope (for example, in a non-async method), the lifetime is no longer managed for us. The task runs independently until it completes, or until we explicitly cancel it.
To be able to cancel a task, we can store it:
class DataService {private var task: Task<Void, Never>?func startWork() {task = Task {await loadData()}}func cancelWork() {task?.cancel()}}
An important thing to note here is that calling cancel() doesn't immediately stop the task, it only sets a cancellation flag that async operations can check. Swift does not forcibly terminate running work. Instead, async calls and loops can choose when to respect that flag.
Some system functions, such as URLSession.shared.data(from:) or Task.sleep(for:), automatically check for cancellation and throw a CancellationError. For our own async code, we can check manually using Task.checkCancellation() or Task.isCancelled.
Swift also provides a second kind of unstructured task: Task.detached. While a regular Task { ... } inherits the caller's priority, actor context, or cancellation state (if any), a detached task runs completely independently. In our example above, using Task.detached would behave the same in practice. But in other contexts, for instance when working inside actors or structured scopes, these differences can lead to noticeably different behavior. We'll look at the nuances between Task and Task.detached in an upcoming article.
Not every unstructured or detached task needs to be cancelled manually. If the task is doing something finite, for example performing a single network request, it will just run to completion.
Long-running tasks
Where manual cancellation becomes important is with long-lived work. In those cases, the task doesn't naturally end on its own.
For example, when working with AsyncStream, we may have code that listens to updates as follows:
Task {for await update in await service.updates {print("Received:", update)}}
This task will keep running indefinitely, until either the stream finishes or the task is cancelled. In cases like this, it's a good idea to either use a structured task or store the task in a property to be able to stop it when needed.
It's also good to know how AsyncStream behaves on cancellation: when the task is cancelled, the consuming side of the stream stops receiving values because AsyncStream internally checks for cancellation and drops any values sent to a terminated continuation. However, the producing side (the code yielding values into the stream) doesn't automatically stop. It will keep running unless we handle cleanup explicitly.
When working with long-lived tasks like streaming updates, it's important to decide where a task's lifetime should end: either automatically through structure, or explicitly through stored references and manual cancellation.



Newsletter
Like to support my work?
Say hi
Related tags
Articles with related topics
Latest articles and tips



