top of page

01 · INTRODUCTION

What Is a Task Group in Swift?

The short answer

A task group is a structured-concurrency scope that creates and manages a dynamic number of child tasks. You add child operations while the group is running, then consume their results as those children finish.

CODE EXAMPLE

func fetchAllLaunches(
    from providers: [any LaunchProviding]
async throws -> [Launch] {
    try await withThrowingTaskGroup(of: [Launch].self) { group in
        for provider in providers {
            group.addTask {
                try await provider.fetchLaunches()
            }
        }

        var launches: [Launch] = []

        for try await providerLaunches in group {
            launches += providerLaunches
        }

        return launches
    }
}

If the app receives three providers, the group creates three child tasks. If it receives ten providers, it can create ten. The parent operation does not need to know the number while the function is being written.

The central idea: a task group turns a runtime-sized collection of operations into one bounded parent operation.

02 · TUTORIAL

Why async let Is Not Always Enough

async let works well when the child operations are fixed and named.

CODE EXAMPLE

async let spacex = spacexProvider.fetchLaunches()
async let nasa = nasaProvider.fetchLaunches()
async let esa = esaProvider.fetchLaunches()

let (spacexLaunches, nasaLaunches, esaLaunches) =
    try await (spacex, nasa, esa)

let launches = spacexLaunches + nasaLaunches + esaLaunches

This code knows at compile time that there are exactly three providers. A production application may instead receive its providers through configuration, dependency injection or feature availability.

CODE EXAMPLE

let providers: [any LaunchProviding]

The number of operations now belongs to runtime data. A task group lets the parent create one structured child for each element in that collection.

03 · TUTORIAL

The Group Defines a Structured Scope

You do not initialise and retain a TaskGroup directly. Swift passes one into the closure of withTaskGroup or withThrowingTaskGroup.

CODE EXAMPLE

await withTaskGroup(of: [Launch].self) { group in
    // Add and consume child tasks here.
}

The group exists only inside this closure. Its child tasks cannot escape as independently managed task handles.

EXECUTION DIAGRAM

Parent task
    │
    ▼
withTaskGroup scope
    ├─ provider child
    ├─ provider child
    └─ provider child
    │
    ▼
scope exits after every child has finished

When the group function returns, the group is empty. Every added child has completed, been consumed or been implicitly awaited. This is the structured lifetime guarantee established in the previous article.

04 · TUTORIAL

Every Child Produces the Group’s Element Type

The type passed to withTaskGroup(of:) is the result type produced by each child.

CODE EXAMPLE

withTaskGroup(of: [Launch].self)

Each child in this group must return [Launch].

CODE EXAMPLE

group.addTask {
    try await provider.fetchLaunches()
}

The group itself can return a different type. Its body may combine all child arrays, remove duplicates, sort the launches and return one final array.

Child-task result is the value produced by each child. Group result is the final value returned by the task-group body to its caller.

05 · TUTORIAL

addTask Creates a Structured Child

Each call to group.addTask submits a sendable asynchronous closure as a new child of the parent task that owns the group.

CODE EXAMPLE

for provider in providers {
    group.addTask {
        try await provider.fetchLaunches()
    }
}

The child may begin before the loop has finished adding the remaining children. Task creation and result production can overlap.

The child inherits structured task context such as cancellation status, priority and task-local values. It does not become a new thread, and the group does not promise parallel execution.

06 · TUTORIAL

Child Closures Must Be Safe to Transfer

The operation passed to addTask is a @Sendable closure. Its captured values must satisfy Swift’s concurrency-safety rules.

CODE EXAMPLE

for provider in providers {
    group.addTask {
        try await provider.fetchLaunches()
    }
}

Each loop iteration captures its provider value for the child operation. The provider abstraction must therefore be safe to use across the new task boundary.

CODE EXAMPLE

protocol LaunchProviding: Sendable {
    var name: String { get }
    func fetchLaunches() async throws -> [Launch]
}

The concepts now connect: structured concurrency owns the child’s lifetime, while Sendable and @Sendable protect the values and closure crossing into that child.

07 · TUTORIAL

Results Arrive in Completion Order

A task group is an asynchronous sequence of completed child results.

CODE EXAMPLE

for await providerLaunches in group {
    launches += providerLaunches
}

The loop yields whichever child finishes next. It does not preserve the order in which children were added.

EXECUTION DIAGRAM

Submitted:   SpaceX ─▶ NASA ─▶ ESA

Completed:  ESA ─────▶ SpaceX ─────▶ NASA

Consumed:   ESA ─────▶ SpaceX ─────▶ NASA

This behaviour is useful because a slow provider does not prevent the parent from processing results that are already available. It also means collection order is nondeterministic.

08 · TUTORIAL

Ordering Must Be an Explicit Decision

If the final launch list needs chronological order, sort it after collecting every result.

CODE EXAMPLE

for await providerLaunches in group {
    launches += providerLaunches
}

return launches.sorted { $0.date < $1.date }

If results must preserve provider order, each child can return its original index.

CODE EXAMPLE

try await withThrowingTaskGroup(of: (Int, [Launch]).self) { group in
    for (index, provider) in providers.enumerated() {
        group.addTask {
            (index, try await provider.fetchLaunches())
        }
    }

    var indexedResults: [(Int, [Launch])] = []

    for try await result in group {
        indexedResults.append(result)
    }

    return indexedResults
        .sorted { $0.0 < $1.0 }
        .flatMap(\.1)
}

Concurrency removes accidental ordering. If order has meaning, the program must model and restore it deliberately.

09 · TUTORIAL

withTaskGroup Is for Nonthrowing Children

Use withTaskGroup when each child returns normally.

CODE EXAMPLE

func fetchCachedLaunches(
    from caches: [LaunchCache]
async -> [Launch] {
    await withTaskGroup(of: [Launch].self) { group in
        for cache in caches {
            group.addTask {
                await cache.readLaunches()
            }
        }

        var launches: [Launch] = []

        for await cached in group {
            launches += cached
        }

        return launches
    }
}

The child result type does not include an error. The parent consumes values with for await.

10 · TUTORIAL

withThrowingTaskGroup Is for Throwing Children

Network providers can fail, so a fail-fast loader can use a throwing task group.

CODE EXAMPLE

func fetchAllLaunches(
    from providers: [any LaunchProviding]
async throws -> [Launch] {
    try await withThrowingTaskGroup(of: [Launch].self) { group in
        for provider in providers {
            group.addTask {
                try await provider.fetchLaunches()
            }
        }

        var launches: [Launch] = []

        for try await providerLaunches in group {
            launches += providerLaunches
        }

        return launches
    }
}

If the next completed child throws, iteration throws. Letting that error leave the group body cancels unfinished children, waits for them to finish and then propagates the error to the caller.

This is appropriate when every provider is required to produce a valid overall result.

11 · TUTORIAL

A Throwing Group Does Not Automatically Mean Fail Fast

A child error is stored as that child’s result until the parent observes it. The parent determines the policy when it consumes group results.

For a launch aggregator, one unavailable provider may not justify discarding successful data from every other provider. Each child can convert its own outcome into a value:

CODE EXAMPLE

struct ProviderResult: Sendable {
    let providerName: String
    let result: Result<[Launch], ProviderFailure>
}

CODE EXAMPLE

await withTaskGroup(of: ProviderResult.self) { group in
    for provider in providers {
        group.addTask {
            do {
                let launches = try await provider.fetchLaunches()
                return ProviderResult(
                    providerName: provider.name,
                    result: .success(launches)
                )
            } catch {
                return ProviderResult(
                    providerName: provider.name,
                    result: .failure(.unavailable)
                )
            }
        }
    }

    var results: [ProviderResult] = []

    for await result in group {
        results.append(result)
    }

    return results
}

The group is nonthrowing because each child returns a value describing success or failure. This preserves partial results and lets the parent decide what the user should see.

Task groups provide mechanics. Your feature still defines whether one failed child invalidates the whole operation.

12 · TUTORIAL

The Parent Should Combine Results

Child tasks should generally return values instead of mutating one captured array.

CODE EXAMPLE

var launches: [Launch] = []

for provider in providers {
    group.addTask {
        let result = try await provider.fetchLaunches()
        launches += result
        // Error: unsafe mutation of captured state.
    }
}

The child closures execute concurrently. Sharing the parent’s mutable array would reintroduce exactly the overlapping access that Swift’s concurrency model is designed to prevent.

The safe flow is:

EXECUTION DIAGRAM

Children fetch independently
          │
          ▼
Children return Sendable values
          │
          ▼
Parent consumes one result at a time
          │
          ▼
Parent mutates its local accumulator

The parent is the single owner of result aggregation.

13 · TUTORIAL

Cancellation Applies to the Whole Group

A task group becomes cancelled when its parent task is cancelled, when the group body exits by throwing, or when code calls group.cancelAll().

CODE EXAMPLE

if let firstSuccessfulResult = await group.next() {
    group.cancelAll()
    return firstSuccessfulResult
}

cancelAll() marks unfinished children as cancelled. It does not forcibly terminate their instructions. Each child must reach a cancellation-aware suspension point or check cancellation itself.

CODE EXAMPLE

group.addTask {
    try Task.checkCancellation()
    return try await provider.fetchLaunches()
}

The group still waits for its children before returning. Cancellation changes what the children should do; it does not remove the structured lifetime guarantee.

14 · TUTORIAL

Do Not Add Unlimited Work Without Thought

A loop can add thousands of child tasks almost immediately.

CODE EXAMPLE

for endpoint in endpoints {
    group.addTask {
        try await endpoint.fetch()
    }
}

The runtime schedules tasks efficiently, but a task group is not automatically a concurrency limiter. Thousands of tasks may still create excessive network requests, memory pressure or pressure on an external service.

For a small provider list, adding one child per provider is reasonable. For a large collection, use a rolling window.

15 · TUTORIAL

Bounded Concurrency Uses a Rolling Window

The parent can seed a limited number of children, then add one replacement whenever a result finishes.

CODE EXAMPLE

func fetchBounded(
    from providers: [any LaunchProviding],
    limit: Int
async throws -> [Launch] {
    guard limit > 0 else { return [] }

    return try await withThrowingTaskGroup(of: [Launch].self) { group in
        var iterator = providers.makeIterator()

        for _ in 0..<min(limit, providers.count) {
            guard let provider = iterator.next() else { break }
            group.addTask {
                try await provider.fetchLaunches()
            }
        }

        var launches: [Launch] = []

        while let result = try await group.next() {
            launches += result

            if let provider = iterator.next() {
                group.addTask {
                    try await provider.fetchLaunches()
                }
            }
        }

        return launches.sorted { $0.date < $1.date }
    }
}

At most limit provider children are unfinished at once. The group remains dynamic and structured, while the parent applies back pressure by delaying creation of later children.

16 · TUTORIAL

A Complete Launch Aggregator

CODE EXAMPLE

struct Launch: Sendable, Identifiable {
    let id: String
    let name: String
    let date: Date
}

enum ProviderFailure: Error, Sendable {
    case unavailable
}

struct ProviderResult: Sendable {
    let providerName: String
    let result: Result<[Launch], ProviderFailure>
}

struct LaunchManager {
    let providers: [any LaunchProviding]

    func refresh() async -> [Launch] {
        let providerResults = await withTaskGroup(
            of: ProviderResult.self,
            returning: [ProviderResult].self
        ) { group in
            for provider in providers {
                group.addTask {
                    do {
                        return ProviderResult(
                            providerName: provider.name,
                            result: .success(
                                try await provider.fetchLaunches()
                            )
                        )
                    } catch {
                        return ProviderResult(
                            providerName: provider.name,
                            result: .failure(.unavailable)
                        )
                    }
                }
            }

            var results: [ProviderResult] = []

            for await result in group {
                results.append(result)
            }

            return results
        }

        let launches = providerResults.flatMap { result in
            switch result.result {
            case .success(let launches): launches
            case .failure: []
            }
        }

        return Dictionary(grouping: launches, by: \.id)
            .compactMap { $0.value.first }
            .sorted { $0.date < $1.date }
    }
}

The manager creates one child for each configured provider. Children return explicit outcomes. The parent collects those outcomes in completion order, retains successful launches, removes duplicate IDs and restores chronological order.

The feature now has a clear description: it is one refresh task containing a dynamic group of provider tasks.


17 · TUTORIAL

The Complete Mental Model

EXECUTION DIAGRAM

Parent enters task-group scope
              │
              ▼
Adds runtime-sized child tasks
      ┌───────┼───────┐
      ▼       ▼       ▼
 provider  provider  provider
      │       │       │
      └── completion order ──▶ parent accumulator
                              │
                              ▼
             group exits after all children finish

The task group owns child lifetimes. @Sendable closures carry safe inputs into each child. Children return sendable values. The parent serially combines results and restores any ordering required by the feature.


18 · TUTORIAL

What to Remember

• A task group creates a dynamic number of structured child tasks.

• The group exists only inside its lexical scope.

• Every child produces the group’s declared child-result type.

addTask creates a child; it does not create a dedicated thread.

• Results are consumed in completion order, not submission order.

• Required ordering must be restored explicitly.

• Use withTaskGroup for nonthrowing children and withThrowingTaskGroup for throwing children.

• The feature decides between fail-fast and partial-failure behaviour.

• Children should return values; the parent should own result aggregation.

• Cancellation is propagated but remains cooperative.

• A task group does not automatically limit how many children are created.

• A rolling window provides bounded concurrency for large input collections.


19 · TUTORIAL

Frequently Asked Questions

When should I use a task group instead of async let?

Use async let for a fixed set of named child operations. Use a task group when the number of children depends on runtime data.

Does a task group preserve insertion order?

No. Iteration and next() produce results in completion order. Attach an index or sort the final values when order matters.

Can child tasks append to one shared array?

Not safely as ordinary captured mutable state. Have each child return a value, then let the parent accumulate results while consuming the group.

Does one throwing child cancel the group immediately?

The error becomes that child’s result. When the parent observes and lets the error escape the group body, unfinished children are cancelled and awaited.

Can a task-group child outlive the group?

No. The group cannot return until all its children have finished, including children responding to cancellation.

Does a task group limit concurrency automatically?

No. Adding one child per input can create a very large number of tasks. Use a rolling window when the input collection or external resource requires a limit.


21 · TUTORIAL

Continue Learning

Task groups propagate cancellation through a tree of child work, but cancellation is only a request. The next article, What Is Task Cancellation in Swift?, will explain how tasks detect cancellation, stop stale refreshes and prevent old search or navigation work from updating a newer feature state.

22 · TUTORIAL

Download the Xcode Playground

Use the accompanying playground to create one child per launch provider, print results in completion order and then restore chronological order. Compare fail-fast and partial-failure groups, cancel unfinished children after the first useful result and finish by implementing a rolling concurrency limit.

bottom of page