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.
20 · TUTORIAL
References
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.
