Question 8
Parallelism with async let and task groups
You need to load several independent resources for a screen. When do you reach for async let vs TaskGroup, and how do errors and cancellation behave?
Answer outline
Reach for async let when the number of parallel operations is small and fixed at compile time. Reach for withThrowingTaskGroup when the count comes from runtime data, such as an array of URLs, or when you want to process results as they arrive.
With async let you declare the bindings and collect them with try await (user, badges, feed), and all three start immediately in parallel. If one throws, the siblings are canceled and the error surfaces at the await. Every async let binding must be awaited, because skipping one silently cancels that child and loses its result.
A task group follows similar rules: when a child's error is rethrown by for try await and escapes the group body, the remaining children are canceled. It adds two things async let cannot do:
- 1.Bounded concurrency: add tasks in batches and drain with
next()between them, so you never spawn thousands at once. - 2.Partial success: type the group as
Result<T, Error>so each child captures its own failure instead of aborting the whole group.
Both tools are structured. Parent cancellation cascades to every child automatically, so prefer them over Task.detached plus manual coordination for any fan-out work.
Principles
- Use
async letfor a fixed fan-out of two to four things known at compile time, and a task group for array-driven fan-out. try await (a, b, c)collects every result at once, whilefor try awaiton the group delivers results as they arrive.- For partial success, use
withTaskGroup(of: Result<T, Error>.self)so one child's failure is captured rather than broadcast. - For bounded concurrency, add tasks in chunks and call
group.next()before adding more so you don't flood the cooperative thread pool.
func loadDashboard() async throws -> Dashboard {
async let user = api.currentUser()
async let badges = api.badges()
async let feed = api.feedPreview()
return try await Dashboard(user: user, badges: badges, feed: feed)
}
func loadAll(_ urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
var out: [Data] = []
for try await data in group {
out.append(data)
}
return out
}
}
func loadAll(_ urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
let batchSize = 4
var index = 0
// Seed the first batch
while index < min(batchSize, urls.count) {
let url = urls[index]
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
index += 1
}
var out: [Data] = []
// As each task finishes, add the next URL to keep four in flight
for try await data in group {
out.append(data)
if index < urls.count {
let url = urls[index]
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
index += 1
}
}
return out
}
}
// withThrowingTaskGroup: the first failure that escapes cancels everything
for try await item in group { ... } // rethrows the first failure
// withTaskGroup plus Result: each child wraps its own error, and none cancel the rest
func loadItems(_ ids: [Int]) async -> [Item] {
await withTaskGroup(of: Result<Item, Error>.self) { group in
for id in ids {
group.addTask {
do { return .success(try await api.fetchItem(id)) }
catch { return .failure(error) }
}
}
var items: [Item] = []
for await result in group {
if case .success(let item) = result { items.append(item) }
}
return items
}
}



