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

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: Task conforms to Sendable and 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 let is 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 need TaskGroup instead. Trying to express dynamic concurrency with async let inside 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, TaskGroup and ThrowingTaskGroup gained a ChildTaskResult associated 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 call cancel() 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.detached is 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 @MainActor isolation (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)

ScenarioRecommendation
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 lifecycleTask { } stored as a property, cancelled on disappear
Work that must survive the lifecycle of any initiating contextTask.detached — use sparingly, document why
Sequential async work with no parallelism neededPlain await in sequence — simpler and zero overhead
Parallel work where a single failure should cancel all siblingswithThrowingTaskGroup — first error cancels the group

Summary

  • async let is the right tool when you know the exact number of parallel operations at compile time — it starts child tasks immediately and joins at the await point.
  • withTaskGroup handles 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, and Task.isCancelled when you need non-throwing cleanup.
  • Task.detached escapes 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.