Chapter 5 — async let: concurrent children

The problem: sequential when you meant concurrent

// SLOW — three independent loads, run one after another.
func loadDashboard() async throws -> Dashboard {
    let user = try await client.fetch(User.self, from: userURL)         // wait 300ms
    let weather = try await client.fetch(Weather.self, from: weatherURL) // THEN wait 250ms
    let news = try await client.fetch([Article].self, from: newsURL)     // THEN wait 400ms
    return Dashboard(user: user, weather: weather, news: news)           // total ≈ 950ms
}
flowchart TB subgraph SEQ__Sequential_await____sum___950ms___ [\"Sequential await (≈ sum = 950ms)\"] U1[user 300] --> W1[weather 250] --> N1[news 400] end subgraph CON__async_let____max___400ms___ [\"async let (≈ max = 400ms)\"] U2[user 300] W2[weather 250] N2[news 400] end

async let starts work immediately

// FAST — three concurrent child tasks; total ≈ the slowest one.
func loadDashboard() async throws -> Dashboard {
    async let user = client.fetch(User.self, from: userURL)       // starts NOW
    async let weather = client.fetch(Weather.self, from: weatherURL) // starts NOW
    async let news = client.fetch([Article].self, from: newsURL)     // starts NOW

    // All three are already running concurrently. Now collect them:
    return try await Dashboard(user: user, weather: weather, news: news)
}

The key distinction: a plain let x = await f() waits for f before continuing. An async let x = f() starts f and continues immediately; the waiting happens later, at the point you await x.

async let is structured

flowchart TB SCOPE["loadDashboard scope"] SCOPE --> C1["child: user"] SCOPE --> C2["child: weather"] SCOPE --> C3["child: news"] C1 & C2 & C3 --> EXIT["scope cannot exit until
all children finish or are cancelled"]
  1. The scope can't exit while children are running. You cannot return from (or fall off the end of)
  2. Errors propagate like normal throws. If any child throws (a network failure), the try await
  3. Cancellation flows down. If the task running loadDashboard is cancelled, all three async let

You must await (or the compiler complains)

func partialUse() async throws -> User {
    async let user = client.fetch(User.self, from: userURL)
    async let weather = client.fetch(Weather.self, from: weatherURL)  // ⚠️ never awaited
    return try await user
    // `weather` is cancelled and awaited here automatically at scope exit.
}

Mixing dependent and independent work

func loadProfileScreen(userID: User.ID) async throws -> ProfileScreen {
    // These two are independent — start both now.
    async let profile = client.fetch(Profile.self, from: profileURL(userID))
    async let recentPosts = client.fetch([Post].self, from: postsURL(userID))

    // But the friends-of-friends list needs the profile first, so await it,
    // then start the dependent work.
    let loadedProfile = try await profile
    async let mutualFriends = client.fetch([Friend].self,
                                           from: mutualsURL(loadedProfile.friendIDs))

    return try await ProfileScreen(
        profile: loadedProfile,
        posts: recentPosts,          // was running concurrently the whole time
        mutuals: mutualFriends
    )
}

When async let isn't enough

flowchart LR Q{"How many concurrent
operations?"} Q -->|"Fixed & known
(this + this + this)"| AL["async let"] Q -->|"Dynamic / N from a collection"| TG["Task group (Ch 6)"]

What we built in this chapter

  • CityDashboard, which loads three independent resources concurrently with async let, cutting
  • The core mechanic: async let starts work immediately (as a child task) and continues without
  • The structured guarantees it brings: children are bound to the scope (no leaks), errors
  • How to mix dependent and independent work by placing awaits at the real data dependencies for
  • Its one limitation — a fixed, compile-time-known number of operations — and the signpost to **task

Mental model to take away

  • let x = await f() waits; async let x = f() starts and moves on. That difference is the
  • async let is structured concurrency: children live and die with the scope, so there's nothing to
  • Express your dependency graph with await placement: start everything independent up front,
  • Reach for async let for a fixed, small set of concurrent operations; reach for a task group