- Published on
- 6 min read Advanced
> TaskGroup and Structured Concurrency Patterns in Swift
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
// 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:
The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.
// Continue_Learning
The Difference Between Frame and Bounds in UIKit
Understand the key difference between a UIView's frame and bounds properties, and when to use each in your iOS apps.
Typed Throws in Swift 6: Declaring Specific Error Types
Swift 6 introduces typed throws, letting you declare exactly which error types a function can throw. Learn how this improves API contracts and enables exhaustive error handling.
Swift Regex Builder: A Type-Safe DSL for Pattern Matching
Learn how to use Swift's RegexBuilder DSL to create readable, type-safe regular expressions with compile-time checking and strongly-typed captures.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.