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
Decision rule: reach for async let when the number of parallel operations is small and fixed at compile time; reach for withThrowingTaskGroup when the count is runtime-driven (e.g. an array length) or you need as-results-come-in processing.
async let — declare the bindings, then collect them with try await (user, badges, feed). All three start immediately in parallel. If one throws, siblings are cancelled and the error propagates at the await line. Every async let binding must be awaited — skipping it silently cancels the child and you lose the result.
Task group — same error and cancellation rules: one throw cancels remaining children. Two extra powers vs async let:
(1) bounded concurrency — add tasks in batches and drain with next() between batches so you don't spawn thousands at once.
(2) partial success — type the group as Result<T, Error> so individual failures are captured rather than aborting the whole group.
Both tools are structured: parent cancellation cascades to all children automatically. Prefer them over Task.detached + manual coordination for any fan-out work.
Principles
- Fixed fan-out (2–4 things known at write time) →
async let. Dynamic fan-out (array-driven) → task group. try await (a, b, c)collects all results atomically;for try await result in groupprocesses results as they arrive — use the latter when you want to update UI progressively.- Partial success: use
withTaskGroup(of: Result<T, Error>.self)so one child's failure is captured, not broadcast. - Bounded concurrency: add tasks in chunks, calling
group.next()to drain before adding more — prevents the cooperative pool from being flooded.
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 (d, _) = try await URLSession.shared.data(from: url); return d }
index += 1
}
var out: [Data] = []
// As each task finishes, add the next URL — keeps exactly 4 in-flight
for try await data in group {
out.append(data)
if index < urls.count {
let url = urls[index]
group.addTask { let (d, _) = try await URLSession.shared.data(from: url); return d }
index += 1
}
}
return out
}
}
// withThrowingTaskGroup: one failure cancels everything
for try await item in group { ... } // throws on first failure, cancels siblings
// withTaskGroup + Result: each child wraps its own error, 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
}
}
Follow-up angles
async letcancellation order: when a parent task throws, all siblingasync lettasks are cancelled. The error surfaces at thetry awaitcollection point, not at theasync letdeclaration line.