← All topics/Concurrency

Practical interview questions

Scenario-style prompts with sample answer outlines. Focus is on how you would design and reason in real codebases.

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 group processes 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.
async let — fixed parallel fetches
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)
}
TaskGroup — dynamic fan-out
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
    }
}
Bounded concurrency — cap in-flight tasks to avoid flooding the pool
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
    }
}
Partial success — collect results, skip failures
// 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 let cancellation order: when a parent task throws, all sibling async let tasks are cancelled. The error surfaces at the try await collection point, not at the async let declaration line.