We’ve already covered async let, which, in a way, allowed us to group tasks inside an awaited array. However, it’s not a solution in which we can programmatically combine multiple tasks based on, for example, a for-each loop. This is where Task Groups come into place.
Task Groups in Swift allow you to combine multiple parallel tasks and wait for the result to return when all tasks are finished. They are commonly used for scenarios like combining multiple API request responses into a single response object.
What is a Task Group?
You can see a Task Group as a container of several child tasks that are dynamically added. Child tasks can run in parallel or serial, but the Task Group will only be marked as finished once its child tasks are done.
You can create a task group using the withTaskGroup method, which creates a group of tasks and returns a value after all the added child tasks are finished. You can think of it as a forEach that runs in parallel, and you collect results as each child finishes. An example looks as follows:
let results: [Int] = await withTaskGroup(of: Int.self) { group in
group.addTask { 1 }
group.addTask { 2 }
return await group.reduce(into: []) { $0.append($1) }
}
Which returns [1, 2] once both tasks finished.
A common, more practical, example could be downloading several images from a photo gallery:
await withTaskGroup(of: UIImage.self) { taskGroup in
let photoURLs = await listPhotoURLs(inGallery: "Amsterdam Holiday")
for photoURL in photoURLs {
taskGroup.addTask { await downloadPhoto(url: photoURL) }
}
}
We first fetch the gallery’s photo URLs in the above example. Note that this task doesn’t link to our group and, with that, doesn’t influence the state of the task group. Secondly, we iterate over each photo URL and download them in parallel. The withTaskGroup method will return once all photos are downloaded.
How to use a Task Group
You can group tasks in several ways, including handling errors or returning the final collection of results. They’re a more advanced alternative to async let and allow dynamically adding tasks.
Returning the final collection of results
The first example shared in this article covered combining tasks without returning the final collection of images. We could rewrite that example and return the collection of images instead:
let images: [UIImage] = await withTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
let photoURLs = await listPhotoURLs(inGallery: "Amsterdam Holiday")
for photoURL in photoURLs {
taskGroup.addTask { await downloadPhoto(url: photoURL) }
}
var images = [UIImage]()
for await result in taskGroup {
images.append(result)
}
return images
}
We defined the return type as a collection of images using [UIImage].self. After starting all child tasks, we use an async sequence (more about these later in this course) to await the following result and append the outcome image to our results collection.
Tasks groups conform to AsyncSequence, allowing us to rewrite the above code using a reduce operator:
let images = await withTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
let photoURLs = await listPhotoURLs(inGallery: "Amsterdam Holiday")
for photoURL in photoURLs {
taskGroup.addTask { await downloadPhoto(url: photoURL) }
}
return await taskGroup.reduce(into: [UIImage]()) { partialResult, name in
partialResult.append(name)
}
}
Other operations like map and flatMap can also be used, allowing for flexible solutions to create the outcome. Finally, you can use the collection of images to continue your workflow.
Handling errors by using a throwing variant
It’s common for image downloading methods to throw an error on failure. We can rewrite our example to handle these cases by renaming withTaskGroup to withThrowingTaskGroup:
let images = try await withThrowingTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
let photoURLs = try await listPhotoURLs(inGallery: "Amsterdam Holiday")
for photoURL in photoURLs {
taskGroup.addTask { try await downloadPhoto(url: photoURL) }
}
return try await taskGroup.reduce(into: [UIImage]()) { partialResult, name in
partialResult.append(name)
}
}
Note that we added the try keyword before each async method to handle potential errors. The outcome will be a ThrowingTaskGroup that returns only the image results if none of the requests throw an error.
Failing a group when a child task throws
When one of the child tasks in a task group throws an error, it doesn’t automatically stop the other tasks in that group right away. However, if you throw an error from within the main body of the withThrowingTaskGroup call itself, it cancels the entire group, along with all its child tasks.
This is better explained by the following code examples. In the following task group, nothing is cancelled and the group doesn’t throw an error:
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { throw SomeError() }
}
This could be a task group setup where you would not return results while performing a group of tasks. For example, uploading a set of images without returning a success state.
However, in the following code example, we explicitly unwrap the child task result by using group.next(). This does result in the group rethrowing the error and cancelling any still-running tasks:
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { throw SomeError() }
try await group.next()
}
Errors in child tasks aren’t propagated to the outer task group. Only the child task itself will be marked as failed. To forward the error to our task group, you need to iterate over the outcome results by using methods like reduce in the previous example or next() in the following example:
try await withThrowingTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
let photoURLs = try await listPhotoURLs(inGallery: "Amsterdam Holiday")
for photoURL in photoURLs {
taskGroup.addTask { try await downloadPhoto(url: photoURL) }
}
var images = [UIImage]()
/// Note the use of `next()` to propagate failures to the task group:
while let downloadImage = try await taskGroup.next() {
images.append(downloadImage)
}
return images
}
The next() method receives errors from individual tasks, allowing you to handle them accordingly. In this case, we forward the error to the group closure, making the entire task group fail. Any other running child tasks will be canceled at this point.
What is next()?
The
next()method is like calling.next()on an iterator — it gives you one result at a time, but in random completion order. It also throws if a child task fails.while let result = try await taskGroup.next() { print("Got a new result:", result) }
This loop continues until all child tasks are completed. If any of them throws, the loop also throws and finishes the task group.
So, to summarize:
- Use
withTaskGroupwhen your tasks can’t fail. - Use
withThrowingTaskGroupwhen any child might throw. - To make the group fail early if a task throws, iterate over the results using methods like
next()— otherwise, errors are silently ignored.
Avoid concurrent mutation
You must realize you shouldn’t mutate a task group from outside the task where you created it. For example, please don’t pass around a task group so you can add child tasks to it from another task context. In most cases, you should be warned by the Swift compiler when you do since mutating operations like this can’t be performed from a concurrent execution context like a child task.
Cancellations in groups
Imagine one photo download fails — if you don’t handle cancellation, the app wastes time downloading the rest. Task groups allow you to cancel all other tasks efficiently.
You can cancel a group of tasks by canceling the task it’s running in or by calling the cancelAll() method on the group itself.
taskGroup.cancelAll()
When tasks are added to a canceled group using the [addTask()](https://developer.apple.com/documentation/swift/taskgroup/addtask\(priority:operation:\)), they’ll be canceled directly after creation. It will stop its work directly depending on whether that task respects cancelation correctly. Optionally, you can use [addTaskUnlessCancelled()](https://developer.apple.com/documentation/swift/taskgroup/addtaskunlesscancelled\(priority:operation:\)) to prevent the task from starting:
for photoURL in photoURLs {
let didAddTask = taskGroup.addTaskUnlessCancelled {
try await downloadPhoto(url: photoURL)
}
print("Added task: \(didAddTask)")
}
async let vs. TaskGroup
Now that we’ve covered both async let and TaskGroup, it’s a good moment to compare both. While they behave somewhat similarly, they serve different purposes:
| Feature | async let | TaskGroup |
|---|---|---|
| Scope-bound | Yes | No |
| Automatic Cancellation | On scope exit | Must cancel manually |
| Error Handling | Stops at first thrown error | Manual control over failures |
| Fine-grained control | No (only at await) | Yes (e.g., cancel specific tasks) |
Proposal SE-317 async let bindings also describes async let as:
This proposal aims to make the common task of spawning child tasks to run asynchronously and pass their eventual results up to their parent, using lightweight syntax similar to let bindings.
You could say async let is a lightweight alternative to TaskGroup and a simpler way to execute tasks in parallel. When you need dynamic task spawning and fine-grained control over cancellations, it’s better to use a TaskGroup.
Summary
Task groups efficiently bundle tasks, offering a dynamic alternative to async let. They enable parallel execution while handling errors and cancellations. Using next(), you can propagate failures properly. The result is returned once all tasks complete, making task groups ideal for managing multiple request responses.
In the next lesson, we’re going to look into the discarding task groups.