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.

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:

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 Foundation
func 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

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

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 Foundation
struct 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

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.

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.