BS
BleepingSwift
Published on
6 min read
Advanced

> TaskGroup and Structured Concurrency Patterns in Swift

Share:

Swift's structured concurrency model ensures async tasks have clear lifetimes and proper cleanup. At the heart of this is TaskGroup, which lets you spawn multiple child tasks and collect their results.

Basic TaskGroup Usage

Create a task group with withTaskGroup and add child tasks:

Swift
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
    }
}

Child tasks run concurrently. The for try await loop collects results as they complete, not in submission order.

Preserving Order

If you need results in the original order, track indices:

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

        var results: [(Int, User)] = []
        for try await result in group {
            results.append(result)
        }

        return results.sorted { $0.0 < $1.0 }.map { $0.1 }
    }
}

Limiting Concurrency

By default, TaskGroup will run as many concurrent tasks as the system allows. To limit concurrency, control how many tasks are in flight:

Swift
func fetchWithLimit(ids: [Int], maxConcurrent: Int) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        var iterator = ids.makeIterator()
        var users: [User] = []

        // Start initial batch
        for _ in 0..<min(maxConcurrent, ids.count) {
            if let id = iterator.next() {
                group.addTask { try await fetchUser(id: id) }
            }
        }

        // As each completes, start the next
        for try await user in group {
            users.append(user)
            if let id = iterator.next() {
                group.addTask { try await fetchUser(id: id) }
            }
        }

        return users
    }
}

This keeps exactly maxConcurrent tasks running until the work is exhausted.

Error Handling

When a child task throws, the group cancels remaining tasks and propagates the error:

Swift
func fetchAllOrFail(ids: [Int]) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask {
                try await fetchUser(id: id)  // If any throws, all cancel
            }
        }

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

To collect partial results and handle errors individually:

Swift
func fetchAllWithErrors(ids: [Int]) async -> ([User], [Error]) {
    await withTaskGroup(of: Result<User, Error>.self) { group in
        for id in ids {
            group.addTask {
                do {
                    return .success(try await fetchUser(id: id))
                } catch {
                    return .failure(error)
                }
            }
        }

        var users: [User] = []
        var errors: [Error] = []

        for await result in group {
            switch result {
            case .success(let user):
                users.append(user)
            case .failure(let error):
                errors.append(error)
            }
        }

        return (users, errors)
    }
}

Cancellation

Tasks check for cancellation cooperatively. When a group is cancelled, child tasks receive the signal but must check for it:

Swift
func processItems(_ items: [Item]) async throws -> [Result] {
    try await withThrowingTaskGroup(of: Result.self) { group in
        for item in items {
            group.addTask {
                // Check cancellation before expensive work
                try Task.checkCancellation()

                let processed = await expensiveProcessing(item)

                // Check again after long operations
                try Task.checkCancellation()

                return processed
            }
        }

        var results: [Result] = []
        for try await result in group {
            results.append(result)
        }
        return results
    }
}

You can also cancel a group explicitly:

Swift
func fetchWithTimeout(ids: [Int], timeout: Duration) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        // Timeout task
        group.addTask {
            try await Task.sleep(for: timeout)
            throw TimeoutError()
        }

        // Work tasks
        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
    }
}

struct TimeoutError: Error {}

Discarding Results

When you don't need results, use withDiscardingTaskGroup:

Swift
func sendNotifications(to userIds: [Int]) async throws {
    try await withThrowingDiscardingTaskGroup { group in
        for id in userIds {
            group.addTask {
                try await sendNotification(to: id)
            }
        }
        // No collection loop needed - results are discarded
    }
}

This is more efficient when you only care about completion, not return values.

Pipeline Pattern

Chain task groups for multi-stage processing:

Swift
func processAndUpload(items: [RawItem]) async throws -> [URL] {
    // Stage 1: Process items in parallel
    let processed = try await withThrowingTaskGroup(of: ProcessedItem.self) { group in
        for item in items {
            group.addTask { try await process(item) }
        }
        return try await group.reduce(into: []) { $0.append($1) }
    }

    // Stage 2: Upload processed items in parallel
    let urls = try await withThrowingTaskGroup(of: URL.self) { group in
        for item in processed {
            group.addTask { try await upload(item) }
        }
        return try await group.reduce(into: []) { $0.append($1) }
    }

    return urls
}

First Result Wins

Sometimes you want the first successful result and can cancel the rest:

Swift
func fetchFromMirrors(url: URL, mirrors: [URL]) async throws -> Data {
    try await withThrowingTaskGroup(of: Data.self) { group in
        // Try all mirrors concurrently
        for mirror in [url] + mirrors {
            group.addTask {
                try await downloadData(from: mirror)
            }
        }

        // Return first success, cancel rest
        if let data = try await group.next() {
            group.cancelAll()
            return data
        }

        throw NoMirrorsAvailableError()
    }
}

struct NoMirrorsAvailableError: Error {}

TaskGroup vs async let

Use async let for a fixed, small number of concurrent operations:

Swift
async let user = fetchUser()
async let posts = fetchPosts()
async let followers = fetchFollowers()

let profile = try await Profile(user: user, posts: posts, followers: followers)

Use TaskGroup when the number of tasks is dynamic or large:

Swift
// Dynamic number of tasks - use TaskGroup
let users = try await withThrowingTaskGroup(of: User.self) { group in
    for id in userIds {
        group.addTask { try await fetchUser(id: id) }
    }
    return try await group.reduce(into: []) { $0.append($1) }
}

Both provide structured concurrency guarantees: child tasks can't outlive their scope, and cancellation propagates automatically.

Sample Project

Want to see this code in action? Check out the complete sample project on GitHub:

View on GitHub

The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.