Logo for tanaschita.com

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.

Sponsorship logo
Architecture & Design Patterns for iOS
This book is a practical guide to essential architectural principles and design patterns for iOS development. It covers strategies for building maintainable apps with Swift and SwiftUI, including dependency injection, navigation, common patterns, and modularization.
LEARN MORE

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.

Sponsorship logo
Preparing for a technical iOS job interview
Preparing for a technical iOS Job Interview with over 300 questions & answers. Covering Swift & Objective-C, SwiftUI & UIKit, Combine, HTTP Networking, iOS File System, Core Data, Concurrency with async/await, Security, Automated Testing, Dependency Management, AI & Machine Learning and more.
LEARN MORE
Sponsorship logo
Architecture & Design Patterns for iOS
This book is a practical guide to essential architectural principles and design patterns for iOS development. It covers strategies for building maintainable apps with Swift and SwiftUI, including dependency injection, navigation, common patterns, and modularization.
LEARN MORE
Sponsorship logo
Become a sponsor of tanaschita.com
By publishing an article on different iOS topics every week, tanaschita.com is constantly growing in the developer community and may provide a great audience for you as a sponsor.
CLICK TO LEARN MORE

Newsletter

Image of a reading marmot
Subscribe

Like to support my work?

Say hi

Related tags

Articles with related topics

swiftui

swift

concurrency

async/await

ios

Building responsive and reliable search in SwiftUI

Learn how to debounce search input, cancel obsolete requests and prevent stale results using Swift concurrency.

14 Sep 2026 · 6 min read

Latest articles and tips

© 2026 tanaschita.com

Privacy policy

Impressum