iosNudge

What is a task group, and when do you use it instead of async let?

MediumConcurrency 3 min read

TL;DR

A task group runs many child tasks in parallel and lets you collect their results. Use it when the number of tasks is dynamic, like "download all these URLs". Use async let when you have a fixed number, like 2 or 3 calls. Child tasks are structured: the group waits for all of them, and cancelling the parent cancels them all.

Basic example

func fetchAllUsers(ids: [Int]) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask {
                try await fetchUser(id: id)
            }
        }

        var users: [User] = []
        for try await user in group {
            users.append(user)
        }
        return users
    }
}

All fetches run at the same time. The group finishes when every child is done.

Results come in completion order

Results arrive in the order tasks finish, not the order you added them. If order matters, return the index too.

func fetchInOrder(ids: [Int]) async throws -> [User] {
    try await withThrowingTaskGroup(of: (Int, User).self) { group in
        for (index, id) in ids.enumerated() {
            group.addTask { (index, try await fetchUser(id: id)) }
        }

        var result = [User?](repeating: nil, count: ids.count)
        for try await (index, user) in group {
            result[index] = user
        }
        return result.compactMap { $0 }
    }
}

Group types

FunctionUse when
withTaskGroup(of:)Children do not throw
withThrowingTaskGroup(of:)Children can throw
withDiscardingTaskGroupYou do not need results (fire many side effects)
withThrowingDiscardingTaskGroupSame, but children can throw

Discarding groups free each child's memory as soon as it finishes. They are good for long-running servers or loops that handle many events.

Errors and cancellation

In a throwing group, if a child throws and you rethrow it (for example with for try await), the group cancels all other children, waits for them, and then throws.

do {
    let users = try await fetchAllUsers(ids: [1, 2, 3])
} catch {
    // one fetch failed; the others were cancelled
}

You can also cancel manually:

group.cancelAll()

Cancellation is cooperative. Children must check Task.isCancelled or call cancellation-aware APIs like Task.sleep and URLSession.

Limit how many run at once

Adding 1,000 tasks starts 1,000 downloads. Limit it with a sliding window:

func downloadAll(_ urls: [URL], maxConcurrent: Int = 4) async throws -> [Data] {
    try await withThrowingTaskGroup(of: Data.self) { group in
        var results: [Data] = []
        var iterator = urls.makeIterator()

        for _ in 0..<maxConcurrent {
            guard let url = iterator.next() else { break }
            group.addTask { try await download(url) }
        }

        while let data = try await group.next() {
            results.append(data)
            if let url = iterator.next() {
                group.addTask { try await download(url) }
            }
        }
        return results
    }
}

This is a very common interview follow-up.

Task group vs async let

async letTask group
Number of tasksFixed, known in codeDynamic, from data
Result typesEach can differAll the same type
SyntaxVery shortMore code
StructuredYesYes
async let profile = fetchProfile()
async let feed = fetchFeed()
let (p, f) = try await (profile, feed)   // two different types: async let fits

Rules

  • Do not let the group escape the closure. The compiler stops you.
  • Only add tasks from the task that owns the group, not from inside child tasks.
  • The closure passed to addTask is sending, so captured values must be safe to send.
  • Child tasks inherit priority and task-local values. They do not inherit actor isolation.

Common follow-up questions

  • What happens if I do not iterate the results? The group still waits for all children before withTaskGroup returns.
  • Can child tasks update a shared array? Not directly. That would be a data race. Return values and collect them in the parent.
  • How is this different from DispatchGroup? A task group is structured: it handles cancellation, errors and results automatically, and the compiler checks safety.
  • Do I need of: every time? Since Swift 6.1 the child result type can often be inferred, so of: is optional in simple cases.
  • What is addTaskUnlessCancelled? It adds a child only if the group is not cancelled yet, and returns false otherwise.