Firing off several async calls with DispatchGroup or a manual counter works, but nothing ties the child work's lifetime to the scope that started it — a task can outlive its caller, and cancellation has to be wired up by hand for every branch.
#The old way
func loadDashboard(userID: String, completion: @escaping (Profile, [Order]) -> Void) {
let group = DispatchGroup()
var profile: Profile!
var orders: [Order]!
group.enter()
fetchProfile(userID: userID) { result in
profile = result
group.leave()
}
group.enter()
fetchOrders(userID: userID) { result in
orders = result
group.leave()
}
group.notify(queue: .main) {
completion(profile, orders)
}
}
#The new way
struct Profile { let userID: String; let name: String }
struct Order { let id: String }
func fetchProfile(userID: String) async throws -> Profile {
Profile(userID: userID, name: "Loaded")
}
func fetchOrders(userID: String) async throws -> [Order] {
[Order(id: "1"), Order(id: "2")]
}
func loadDashboard(userID: String) async throws -> (Profile, [Order]) {
async let profile = fetchProfile(userID: userID)
async let orders = fetchOrders(userID: userID)
return try await (profile, orders)
}
func loadManyOrders(userIDs: [String]) async throws -> [Order] {
try await withThrowingTaskGroup(of: [Order].self) { group in
for id in userIDs {
group.addTask { try await fetchOrders(userID: id) }
}
var all: [Order] = []
for try await batch in group {
all.append(contentsOf: batch)
}
return all
}
}
#Why it matters
async letruns both fetches concurrently and the enclosing function cannot return until both completeTaskGrouphandles a dynamic, runtime-determined number of child tasks — a fixedasync letcount can't- Cancelling the parent task automatically propagates cancellation to every child, no manual wiring required
- If one child throws, the group tears down the others instead of leaking orphaned work
#Gotcha
An async let binding that's never awaited still runs, but its child task is implicitly cancelled when it goes out of scope without being awaited — and if the unused work threw, that error is silently discarded rather than surfaced. Always await every async let you create, even ones whose result you plan to ignore, or wrap it in a defer that awaits it explicitly.