Task and Structured Concurrency
Task is the fundamental unit of asynchronous work in Swift concurrency — creating a Task { ... } starts a new asynchronous context that can call await expressions, independent of the surrounding synchronous code. Structured concurrency is the broader principle that async work should form a tree: child tasks are scoped to their parent's lifetime, so when a parent task is cancelled or completes, its children are cancelled too, and a parent cannot finish until its children have. This is a deliberate contrast to older, unstructured patterns like fire-and-forget DispatchQueue.global().async {} blocks, whose lifetime and cancellation had no relationship to the code that created them.
Cricket analogy: A DRS review is scoped to the current over — it can't outlive the match, and if the match is abandoned every pending review is cancelled with it, unlike an old-style unofficial sideline bet with no tie to the game's lifetime.
Unstructured Task Creation
A plain Task { ... } initializer creates what's technically an *unstructured* task — it inherits the current actor context and priority but is not tied to any enclosing scope, so it keeps running even if the code that created it returns. In SwiftUI, the .task view modifier is the idiomatic way to tie a task's lifetime to a view: it starts the task when the view appears and automatically cancels it when the view disappears, which is exactly the behavior you usually want for view-driven data loading.
Cricket analogy: A commentator starting an unstructured side analysis keeps talking even after the broadcast cuts away, but SwiftUI's .task on a live-scorecard view is like a feed that starts when you open the match screen and stops when you leave it.
struct ArticleListView: View {
@State private var articles: [Article] = []
@State private var isLoading = false
var body: some View {
List(articles) { article in
Text(article.title)
}
.overlay { if isLoading { ProgressView() } }
.task {
isLoading = true
defer { isLoading = false }
do {
articles = try await ArticleService.shared.fetchAll()
} catch is CancellationError {
// View disappeared before the fetch finished; nothing to do.
} catch {
print("Failed to load articles: \(error)")
}
}
}
}Task Groups for Structured Concurrency
When you need to launch a dynamic number of child tasks and wait for all of them, withTaskGroup (or withThrowingTaskGroup for tasks that can fail) provides true structured concurrency: every child task added to the group is guaranteed to either complete or be cancelled before the group's enclosing call returns, and cancelling the group cancels every child. This differs from async let, which is structured but fixed at compile time to a specific number of bindings.
Cricket analogy: withTaskGroup is like a fielding coach dynamically assigning a variable number of fielders to review multiple camera angles of a run-out at once, guaranteeing every review finishes or is cancelled before the umpire's decision returns.
func fetchThumbnails(for urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
for url in urls {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw URLError(.cannotDecodeContentData)
}
return image
}
}
var images: [UIImage] = []
for try await image in group {
images.append(image)
}
return images
}
}Cancellation
Swift concurrency's cancellation is cooperative: calling task.cancel() (or a view disappearing while .task is running) sets a flag but does not forcibly stop execution. Long-running async work should periodically check Task.isCancelled or call try Task.checkCancellation(), which throws a CancellationError if the task has been cancelled, so the work can unwind cleanly. Many system APIs, like URLSession's async methods, already check cancellation internally and throw automatically.
Cricket analogy: Calling a DRS review cancel doesn't instantly stop the ball-tracking replay mid-frame; the system must check a cancellation flag between frames and throw, like URLSession's async methods that check cancellation and stop cleanly on their own.
Structured concurrency is often compared to a well-run kitchen: a head chef (the parent task) delegates dishes to line cooks (child tasks), but the head chef cannot declare the meal complete until every delegated dish is either finished or explicitly called off — no dish is ever left cooking unattended after the meal is done, unlike a fire-and-forget kitchen where cooks might keep working on orders nobody wants anymore.
A Task { ... } created inside a SwiftUI action (like a button's closure) is unstructured and outlives the view if the view disappears — it will keep running and can even mutate @State after the view is gone, which triggers a runtime warning. Prefer .task for view-lifecycle-bound work, and only use bare Task { } for genuinely fire-and-forget actions like analytics logging.
Task { ... }creates a new asynchronous context; SwiftUI's.taskmodifier ties a task's lifetime to a view's appearance/disappearance.- Structured concurrency means child tasks cannot outlive their parent scope — the parent waits for all children to finish or be cancelled.
withTaskGroup/withThrowingTaskGrouplet you launch a dynamic number of structured child tasks and collect their results.async letis also structured concurrency, but limited to a fixed, compile-time-known set of bindings.- Cancellation in Swift concurrency is cooperative — code must check
Task.isCancelledor callTask.checkCancellation()to respond to it. - Bare
Task { }blocks are unstructured and can outlive the view that created them, unlike.task.
Practice what you learned
1. What guarantee does structured concurrency provide about a parent task and its child tasks?
2. Why is SwiftUI's `.task` modifier generally preferred over a bare `Task { }` inside a view's body for loading data?
3. How does Swift's cooperative cancellation model work?
4. What is the key difference between `async let` and `withTaskGroup` for structured concurrency?
5. What risk does creating a bare `Task { }` inside a SwiftUI button action introduce?
Was this page helpful?
You May Also Like
async/await Basics in Swift
Learn how Swift's async/await syntax replaces completion-handler callbacks with linear, readable asynchronous code, and how to call async functions safely.
Networking with URLSession
Understand how URLSession performs HTTP requests in Swift, including async/await APIs, request configuration, and handling responses safely.
Combine Framework Fundamentals
Understand Combine's publisher-subscriber model for handling asynchronous event streams in Swift, and how it relates to async/await and SwiftUI.
Publishers and @MainActor
Learn how to safely deliver Combine publisher values to the main thread using @MainActor and .receive(on:), and how actor isolation interacts with reactive pipelines.