Swift Task Groups: Running and Collecting Concurrent Child Tasks
Swift task groups let you start multiple child tasks at the same time and then collect their results in a safe, structured way. They are a core tool for doing concurrent work when you do not know the number of tasks ahead of time, such as loading many URLs, processing a list of files, or combining independent calculations.
Quick answer: Use withTaskGroup when you want to run a dynamic set of child tasks concurrently and gather their results as they finish. Use withThrowingTaskGroup when any child task may fail and you want the group to propagate that error.
Difficulty: Intermediate
You'll understand this better if you know: basic Swift functions, async/await, and how arrays and closures work.
1. What Is Swift Task Groups?
Swift task groups are a structured concurrency feature that lets one parent task create multiple child tasks and wait for all of them to finish. Each child task runs concurrently, and the parent collects their values from the group one by one.
- They are used with withTaskGroup or withThrowingTaskGroup.
- They create child tasks that belong to the current task.
- They are ideal when the number of tasks is dynamic.
- They return results through iteration rather than by indexed placement.
In practice, task groups solve the problem of “I have many independent async operations, and I want to run them together.”
2. Why Swift Task Groups Matter
Without task groups, you might run operations one after another and waste time waiting. With task groups, Swift can overlap the work so the total time is often closer to the slowest task instead of the sum of all tasks.
That matters when you are:
- Fetching multiple network resources at once.
- Reading or transforming many files.
- Running independent computations in parallel.
- Building a result only after several async subtasks complete.
Task groups also help keep concurrency structured. The parent task does not “lose track” of the work it started, which makes cancellation, error propagation, and lifecycle management safer than unmanaged threads or detached tasks.
3. Basic Syntax or Core Idea
A task group starts with a parent async context. Inside the closure, you add child tasks with addTask, then read each returned value as it becomes available.
Minimal non-throwing group
This example shows the smallest useful shape of a task group. The closure receives a mutable group object, and each added task returns a value of the same type.
import Foundationfunc fetchNumber(_ value: Int) async -> Int {
return value * 2
}func doubleNumbers(_ input: [Int]) async -> [Int] {
return await withTaskGroup(of: Int.self) { group in
for number in input {
group.addTask {
await fetchNumber(number)
}
}
var results: [Int] = []
for await result in group {
results.append(result)
}
return results
}
}In this pattern, the group launches several child tasks, then the parent collects every returned Int into an array.
Throwing group syntax
If one child task can fail, use withThrowingTaskGroup. The group closure can throw, and the first thrown error cancels the remaining work.
enum LoadError: Error {
case invalidData
}func loadValue(_ text: String) async throws -> Int {
guard let value = Int(text) else {
throw LoadError.invalidData
}
return value
}4. Step-by-Step Examples
Example 1: Collecting multiple results
This example runs several independent calculations at the same time and returns the finished values in an array.
func square(_ n: Int) async -> Int {
return n * n
}
func squares(for numbers: [Int]) async -> [Int] {
return await withTaskGroup(of: Int.self) { group in
for number in numbers {
group.addTask {
await square(number)
}
}
var output: [Int] = []
for await value in group {
output.append(value)
}
return output
}
}This is the most common shape: add tasks first, then drain the group with for await.
Example 2: Returning a combined result
Sometimes you want a single value built from many child tasks. Here, each task returns a partial string and the parent combines them.
func makeLabels(_ names: [String]) async -> String {
return await withTaskGroup(of: String.self) { group in
for name in names {
group.addTask {
return "Label: \(name)"
}
}
var lines: [String] = []
for await line in group {
lines.append(line)
}
return lines.joined(separator: ", ")
}
}Notice that the group can return any single output type, even if each child task does small, separate work.
Example 3: Early exit with the first successful result
Task groups are also useful when you want the first matching answer and can cancel the rest afterward.
func firstEven(_ numbers: [Int]) async -> Int? {
return await withTaskGroup(of: Int.self) { group in
for number in numbers {
group.addTask {
if number % 2 == 0 {
return number
}
return nil
}
}
for await match in group {
if let match {
group.cancelAll()
return match
}
}
return nil
}
}This pattern is useful for search-like work, where the first answer is enough.
Example 4: Throwing group for network-like work
Here the group can throw if any child task fails. This is common when every result must succeed for the overall operation to continue.
func parseID(_ text: String) async throws -> Int {
guard let id = Int(text) else {
throw LoadError.invalidData
}
return id
}
func parseIDs(_ texts: [String]) async throws -> [Int] {
return try await withThrowingTaskGroup(of: Int.self) { group in
for text in texts {
group.addTask {
try await parseID(text)
}
}
var ids: [Int] = []
for try await id in group {
ids.append(id)
}
return ids
}
}The important detail is that the parent uses try await when collecting from a throwing group.
5. Practical Use Cases
- Load several API endpoints at the same time and combine the responses.
- Transform a batch of files, images, or records in parallel.
- Search multiple independent sources and stop after the first useful match.
- Validate several fields or checks concurrently before producing a final result.
- Aggregate computed values such as totals, rankings, or recommendations from many inputs.
Task groups are strongest when each child task is independent and does not need to wait for the others before starting.
6. Common Mistakes
Mistake 1: Using task groups when a fixed pair of tasks would be clearer
Task groups are flexible, but they are not always the best fit. If you always have exactly two or three async values, async let is usually simpler and easier to read.
Problem: This code uses a task group for a fixed, tiny set of operations, which adds extra ceremony without giving much benefit.
func loadDashboard() async -> (Int, Int) {
return await withTaskGroup(of: Int.self) { group in
group.addTask { 1 }
group.addTask { 2 }
var values: [Int] = []
for await value in group {
values.append(value)
}
return (values[0], values[1])
}
}Fix: Use async let when the number of child tasks is known ahead of time.
func loadDashboard() async -> (Int, Int) {
async let first = 1
async let second = 2
return await (first, second)
}The corrected version works better because it matches the fixed-size nature of the work.
Mistake 2: Forgetting to collect results with for await
A task group does not automatically give you an array. You must iterate over the group to drain its results.
Problem: This code adds tasks but never consumes the group’s values, so it cannot produce the final array.
func doubleAll(_ numbers: [Int]) async -> [Int] {
return await withTaskGroup(of: Int.self) { group in
for number in numbers {
group.addTask { number * 2 }
}
return []
}
}Fix: Use a for await loop to collect each child task result.
func doubleAll(_ numbers: [Int]) async -> [Int] {
return await withTaskGroup(of: Int.self) { group in
for number in numbers {
group.addTask { number * 2 }
}
var results: [Int] = []
for await result in group {
results.append(result)
}
return results
}
}The fixed version works because the parent waits for each result and stores it explicitly.
Mistake 3: Ignoring throwing child tasks
When a child task can fail, the parent must use the throwing version of the API and handle the error properly.
Problem: This code tries to call a throwing async function from a non-throwing task group, which does not match the closure’s error behavior.
func loadAllIDs(_ texts: [String]) async -> [Int] {
return await withTaskGroup(of: Int.self) { group in
for text in texts {
group.addTask {
try await parseID(text)
}
}
return []
}
}Fix: Switch to withThrowingTaskGroup and collect results with try await.
func loadAllIDs(_ texts: [String]) async throws -> [Int] {
return try await withThrowingTaskGroup(of: Int.self) { group in
for text in texts {
group.addTask {
try await parseID(text)
}
}
var ids: [Int] = []
for try await id in group {
ids.append(id)
}
return ids
}
}The corrected version works because the group and the child tasks now agree on error handling.
7. Best Practices
Practice 1: Use task groups for dynamic workloads
Task groups shine when the number of child tasks comes from runtime data, such as an array, file list, or query result.
func processAll(_ items: [String]) async -> [String] {
return await withTaskGroup(of: String.self) { group in
for item in items {
group.addTask { item.uppercased() }
}
var output: [String] = []
for await value in group {
output.append(value)
}
return output
}
}This keeps the structure aligned with the real problem: variable numbers of independent operations.
Practice 2: Keep child tasks independent
Each child task should work without depending on another child task’s internal state. Shared mutable state makes concurrency harder to reason about.
func fetchLengths(_ words: [String]) async -> [Int] {
return await withTaskGroup(of: Int.self) { group in
for word in words {
group.addTask {
return word.count
}
}
var lengths: [Int] = []
for await length in group {
lengths.append(length)
}
return lengths
}
}This is safer because each task only reads its own captured input and returns a value.
Practice 3: Cancel the group when you no longer need the rest
If one result is enough, calling cancelAll can save time and resources.
func findFirstPositive(_ numbers: [Int]) async -> Int? {
return await withTaskGroup(of: Int.self) { group in
for number in numbers {
group.addTask { number }
}
for await number in group {
if number > 0 {
group.cancelAll()
return number
}
}
return nil
}
}Cancelling early helps the group stop unnecessary work once you have the answer you need.
8. Limitations and Edge Cases
- Task groups are structured, so child tasks cannot outlive the parent group closure.
- You cannot directly index results as they finish; results arrive in completion order, not input order.
- If you need input order, you must store indexes yourself or rebuild ordering after collection.
- Child tasks should not strongly depend on shared mutable data without synchronization.
- Task groups are not a replacement for every kind of parallel work; simple fixed-size cases may be better served by async let.
- Cancellation is cooperative. A cancelled task should check for cancellation or reach an await point to stop promptly.
A common surprise is that the output order may differ from the input order because the group yields completed tasks as they finish.
9. Practical Mini Project
Here is a small but complete example that processes a list of page names concurrently, then returns a combined report. Each child task simulates an async lookup, and the parent assembles the final summary.
import Foundationstruct PageReport {
let name: String
let status: String
}
func loadStatus(for name: String) async -> String {
return "ready"
}
func buildReport(for pages: [String]) async -> [PageReport] {
return await withTaskGroup(of: PageReport.self) { group in
for page in pages {
group.addTask {
let status = await loadStatus(for: page)
return PageReport(name: page, status: status)
}
}
var reports: [PageReport] = []
for await report in group {
reports.append(report)
}
return reports
}
}This mini project demonstrates the full lifecycle: create child tasks, await their completion, and combine the results into a single value.
10. Key Points
- Task groups run a dynamic number of child tasks concurrently.
- Use withTaskGroup for non-throwing work and withThrowingTaskGroup for throwing work.
- Collect results with for await or for try await.
- Results are returned in completion order, not input order.
- Use cancelAll when one result is enough.
- Prefer async let for a small, fixed number of tasks.
11. Practice Exercise
Write a function that accepts an array of String values and returns a single String containing only the values that have an even number of characters, processed concurrently with a task group.
- Use a task group to process each string.
- Return the matching strings in any order.
- Skip empty strings.
Expected output: A filtered list of even-length strings combined into one comma-separated string.
Hint: Each child task can return either the original string or nil, and the parent can collect only the non-nil values.
func evenLengthSummary(_ items: [String]) async -> String {
return await withTaskGroup(of: String.self) { group in
for item in items {
group.addTask {
guard !item.isEmpty, item.count % 2 == 0 else {
return ""
}
return item
}
}
var matches: [String] = []
for await match in group {
if !match.isEmpty {
matches.append(match)
}
}
return matches.joined(separator: ", ")
}
}This solution works because each child task decides independently whether its value should be included, and the parent combines the final results safely.
12. Final Summary
Swift task groups are a structured way to run many child tasks concurrently and gather their results in a parent task. They are especially useful when the number of tasks is only known at runtime, such as when processing arrays, files, or multiple network requests.
The most important habits are to choose the right API for throwing or non-throwing work, collect results with for await, and remember that output order is based on completion, not input position. If you only need a small fixed set of async values, consider async let instead.
Once you are comfortable with task groups, the next good step is to practice combining them with cancellation, error handling, and result ordering so you can pick the right concurrency tool for each job.