← All shorts
54Swift 5.5+2 min read

Structured Concurrency: async let and TaskGroup

async let and TaskGroup run child tasks in parallel while guaranteeing they finish before the enclosing scope exits.

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 let runs both fetches concurrently and the enclosing function cannot return until both complete
  • TaskGroup handles a dynamic, runtime-determined number of child tasks — a fixed async let count 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.

swiftconcurrency

Related shorts