Tasks and Task Groups in Swift: Structured Concurrency Explained
Completion handler callbacks gave us the pyramid of doom. async/await flattened the syntax. But managing multiple
concurrent operations — parallel API fan-outs, coordinated cancellation, dynamic work queues — requires understanding
Task and TaskGroup.
This guide covers structured concurrency from async let through withThrowingTaskGroup, including cancellation,
priority, and when to reach for Task.detached. We won’t cover actors or AsyncSequence — those have their own
dedicated posts.
Contents
- The Problem
- Unstructured Tasks with Task
- Parallel Binding with
async let - Dynamic Concurrency with
TaskGroup - Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Imagine loading a Pixar film’s detail page: you need the film metadata, its poster image, and the cast list — three independent network calls that could run in parallel but are routinely written sequentially, or worse, as nested completion handlers.
// ❌ The callback version: nested, fragile, and impossible to cancel
func loadFilmDetails(id: String, completion: @escaping (Film?, UIImage?, [CastMember]?) -> Void) {
fetchFilm(id: id) { film in
guard let film = film else { completion(nil, nil, nil); return }
self.fetchPoster(id: id) { image in
self.fetchCast(id: id) { cast in
completion(film, image, cast)
}
}
}
}
Three problems stand out immediately. The calls run sequentially even though they are independent — poster loading waits
for film metadata to finish. Error handling requires a guard at every nesting level, and the completion closure’s
parameter list (Film?, UIImage?, [CastMember]?) makes the call site look like an accident report. Most importantly,
there is no way to cancel these in-flight requests if the user navigates away.
Swift’s structured concurrency — introduced in SE-0304 — solves all three.
Unstructured Tasks with Task
A Task is the fundamental unit of asynchronous work in Swift.
When you write Task { } inside a synchronous context (a UIViewController, a SwiftUI button action), you are creating
an unstructured task that escapes the current scope.
// Creating an unstructured task from a synchronous context
class FilmDetailViewController: UIViewController {
private var loadTask: Task<Void, Never>?
override func viewDidLoad() {
super.viewDidLoad()
// Capture the task so we can cancel it on disappear
loadTask = Task {
do {
let film = try await fetchFilm(id: "finding-nemo")
await MainActor.run { self.render(film) }
} catch {
await MainActor.run { self.showError(error) }
}
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
loadTask?.cancel() // Cooperative cancellation
}
}
Task inherits the actor context and task-local values of its enclosing scope. Because this task is created inside a
UIViewController (which is not isolated to @MainActor by default in UIKit), you must explicitly hop to MainActor
for UI updates. If you were inside a @MainActor-annotated class, the Task { } body would itself run on the main
actor.
Note:
Taskconforms toSendableand can be stored, passed around, and cancelled from any context. The generic parameters<Success, Failure: Error>let the compiler enforce what a task produces —Task<Film, any Error>,Task<Void, Never>, and so on.
Parallel Binding with async let
async let is the simplest tool for running a fixed, known number of async operations concurrently. Each async let
binding starts a child task immediately; the await at the tuple destructuring point is the join.
@available(iOS 15.0, *)
func loadFindingNemoDetails() async throws -> (Film, UIImage, [CastMember]) {
// All three child tasks start concurrently here
async let filmData = fetchFilm(id: "finding-nemo")
async let posterImage = fetchPoster(id: "finding-nemo")
async let castData = fetchCast(id: "finding-nemo")
// Execution suspends here until all three complete
// If any throws, the remaining child tasks are cancelled automatically
let (film, poster, cast) = try await (filmData, posterImage, castData)
return (film, poster, cast)
}
The beauty of async let is structural: the three child tasks are scoped to the function body. When the function
returns (or throws), Swift guarantees all child tasks have either completed or been cancelled. There is no dangling
work.
Warning:
async letis ideal for a static set of concurrent operations. If the number of operations is determined at runtime — loading details for every film in a catalog — you needTaskGroupinstead. Trying to express dynamic concurrency withasync letinside a loop creates sequential execution, not parallel.
Dynamic Concurrency with TaskGroup
withTaskGroup(of:returning:body:)
is the structured concurrency primitive for a dynamic number of concurrent child tasks. All tasks in the group share the
same lifetime as the group itself.
withTaskGroup for non-throwing operations
@available(iOS 15.0, *)
func loadPixarCatalog(filmIDs: [String]) async -> [PixarFilm] {
await withTaskGroup(of: PixarFilm?.self) { group in
for id in filmIDs {
group.addTask {
// Each addTask call spawns a concurrent child task
await fetchFilm(id: id)
}
}
// Results arrive as each child task completes, not in insertion order
var films: [PixarFilm] = []
for await film in group {
if let film = film {
films.append(film)
}
}
return films
}
}
The for await film in group loop consumes results as they arrive. If fetchFilm for “ratatouille” finishes before
“up” even though “up” was added first, you will receive the ratatouille result first. If ordering matters, you need to
sort after collection or use a dictionary keyed by ID.
withThrowingTaskGroup for failable operations
When child tasks can throw, use
withThrowingTaskGroup.
The first error thrown by any child task cancels the remaining siblings and re-throws from the group.
@available(iOS 15.0, *)
func loadFullCast(filmIDs: [String]) async throws -> [String: [CastMember]] {
try await withThrowingTaskGroup(of: (String, [CastMember]).self) { group in
for id in filmIDs {
group.addTask {
let cast = try await fetchCast(id: id)
return (id, cast)
}
}
var castByFilm: [String: [CastMember]] = [:]
for try await (id, cast) in group {
castByFilm[id] = cast
}
return castByFilm
}
}
Note: In Swift 6,
TaskGroupandThrowingTaskGroupgained aChildTaskResultassociated type, making the generic parameters more explicit. The call sites remain identical to Swift 5.5+.
Advanced Usage
Cooperative Cancellation
Swift concurrency uses a cooperative model — tasks are not forcibly killed. Instead, cancellation sets a flag on the task tree. Your code must check for it.
@available(iOS 15.0, *)
func renderAllScenes(scenes: [Scene]) async throws -> [RenderedFrame] {
try await withThrowingTaskGroup(of: RenderedFrame.self) { group in
for scene in scenes {
group.addTask {
// Check before starting expensive work
try Task.checkCancellation()
let frame = try await renderScene(scene)
// Check again after a suspension point
try Task.checkCancellation()
return frame
}
}
var frames: [RenderedFrame] = []
for try await frame in group {
frames.append(frame)
}
return frames
}
}
Task.checkCancellation() throws
CancellationError if the task has been cancelled. Use
Task.isCancelled when you want to check without
throwing — useful for cleanup code that should run regardless.
// Non-throwing cancellation check
func cleanUpRenderJob(_ job: PixarJob) async {
if Task.isCancelled {
await job.rollback()
return
}
await job.commit()
}
Warning: Calling
task.cancel()on a parent task propagates cancellation down the entire child task tree automatically. You only need to callcancel()once on the root task — not on every child.
Task Priority
TaskPriority gives the Swift concurrency runtime a
hint about scheduling order. It does not guarantee execution order, but the runtime will prefer higher-priority work.
// Spawn a background render job without blocking the current task
let renderTask = Task(priority: .background) {
await renderFarm.processQueue()
}
// High-priority work for user-visible content
let heroTask = Task(priority: .userInitiated) {
try await fetchHeroPoster(id: "coco")
}
Available priorities, in descending order: .high (alias: .userInitiated), .medium (default), .low (alias:
.utility), .background.
Child tasks created with addTask inside a TaskGroup inherit the group’s priority by default. You can override
per-task: group.addTask(priority: .background) { ... }.
Task.detached — When to Escape the Tree
Task.detached creates a task
that does not inherit actor context, task-local values, or priority from its parent. It sits outside the structured
concurrency tree.
// Task.detached is appropriate for fire-and-forget work that
// should not be cancelled if the initiating context is torn down
Task.detached(priority: .background) {
await analyticsLogger.flushPendingEvents()
}
Warning:
Task.detachedis intentionally hard to use. If you find yourself reaching for it often, it is a signal that your task hierarchy needs restructuring. Detached tasks cannot be cancelled by their spawning context, they do not inherit@MainActorisolation (even if the call site is on the main actor), and they are easy to lose track of. Reserve detached tasks for genuinely independent background work like cache writes and analytics flushes.
Performance Considerations
The Swift concurrency runtime manages a cooperative thread pool sized to the number of available CPU cores. Structured task groups tap directly into this pool — adding 50 child tasks to a group does not spawn 50 threads. The runtime schedules them across available threads, suspending and resuming as tasks complete.
Apple Docs:
withTaskGroup— Swift Standard Library
Task.detached bypasses the structured hierarchy and can, in pathological cases, create unbounded concurrency if called
in a tight loop. Structured task groups naturally throttle through the pool’s backpressure.
A common mistake is replacing a serial for await loop with a task group when the loop body has no meaningful
parallelism. If each iteration writes to a shared resource that requires synchronization anyway, the task-switching
overhead of a group is pure cost. Profile before parallelizing.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Fixed number of independent async operations (fetch film + poster + cast) | async let — clearest syntax, compile-time arity |
| Dynamic list of concurrent operations (fetch all films in a catalog) | withTaskGroup or withThrowingTaskGroup |
| Fire-and-forget background work tied to a view/controller lifecycle | Task { } stored as a property, cancelled on disappear |
| Work that must survive the lifecycle of any initiating context | Task.detached — use sparingly, document why |
| Sequential async work with no parallelism needed | Plain await in sequence — simpler and zero overhead |
| Parallel work where a single failure should cancel all siblings | withThrowingTaskGroup — first error cancels the group |
Summary
async letis the right tool when you know the exact number of parallel operations at compile time — it starts child tasks immediately and joins at theawaitpoint.withTaskGrouphandles dynamic concurrency: add as many child tasks as needed, consume results as they arrive, and trust that the group scope ensures all work completes or is cancelled before the function returns.- Cancellation in Swift is cooperative — use
Task.checkCancellation()before and after suspension points in expensive work, andTask.isCancelledwhen you need non-throwing cleanup. Task.detachedescapes the structured tree and loses inherited actor context — it is the right choice only for genuinely fire-and-forget background work.- The Swift concurrency thread pool manages parallelism automatically; structured tasks are always preferable over manually spawned detached tasks.
With structured concurrency in hand, the next natural question is protecting the shared mutable state those tasks might touch. The answer is actors — covered in depth in Actors in Swift: Eliminating Data Races at Compile Time.