Answer
Why interviewers ask this
This question checks whether you understand Swift concurrency as a modeling tool, not just syntax. A strong answer should connect the primitive you choose to the shape of the workload, how results are consumed, and what happens when one child task fails.
Core rule
Use async let when you have a small, fixed set of child tasks that are known where you write the code. Use a task group when the number of child tasks is discovered at runtime, when you want to consume results as they finish, or when you need more explicit control over cancellation and result collection.
Both are part of structured concurrency. The difference is not “safe” versus “unsafe.” The difference is whether the work is statically shaped or dynamically shaped.
1. async let: fixed fan-out
async let is ideal when each child task has a clear name and the parent always awaits all of them before moving on. It keeps the happy path concise and readable.
Not runnable here — this teaching snippet uses async/await, which the in-browser runner does not support yet.
func fetch(_ label: String) async throws -> String {
try await Task.sleep(nanoseconds: 20_000_000)
return label
}
@main
struct Demo {
static func main() async {
do {
async let profile = fetch("profile")
async let history = fetch("history")
let result = try await [profile, history]
print(result)
} catch {
print("Request failed:", error)
// ❌ Once one awaited child throws, the error propagates out of the parent scope.
// Swift then cancels the sibling child task that is still running.
}
}
}This is still the classic async let case: two related requests, both known ahead of time, both normally needed by the parent. The important failure rule is that if one child throws when you try await the results, Swift propagates that error and cancels sibling child tasks that have not finished yet.
2. Task groups: runtime fan-out
Task groups are better when the amount of work is not known until runtime. They let you create child tasks in a loop and iterate over results as children finish.
Not runnable here — this teaching snippet uses Swift structured concurrency features that the in-browser runner does not support yet.
func fetchThumbnail(id: Int) async throws -> String {
try await Task.sleep(nanoseconds: UInt64(5_000_000 * id))
return "thumb-\(id)"
}
@main
struct Demo {
static func main() async {
let ids = [3, 1, 2, 4]
do {
let thumbnails = try await withThrowingTaskGroup(of: String.self) { group in
for id in ids {
// Dynamic fan-out: the parent decides at runtime which jobs exist.
group.addTask {
try await fetchThumbnail(id: id)
}
}
var collected: [String] = []
while let thumbnail = try await group.next() {
// Control point: consume results as children finish, not by input order.
collected.append(thumbnail)
// Policy control: stop once the UI has enough data.
if collected.count == 2 {
group.cancelAll() // ✅ Cancel leftover work once our policy is satisfied.
break
}
}
return collected.sorted()
}
print(thumbnails)
} catch {
// ❌ With a throwing task group, `group.next()` throws on the first child failure it observes.
// Swift then cancels the remaining child tasks still running in the group.
print("Thumbnail fetch failed:", error)
}
}
}This example shows both task-group advantages clearly: the work is discovered dynamically from ids, and the parent controls the stopping policy. The while let thumbnail = try await group.next() loop lets you decide whether to keep collecting, stop after enough successes, or cancel the rest. Because this is a throwing task group, a child failure is also a policy boundary: the first error observed through next() throws out of the group and remaining child tasks are cancelled.
3. Failure and cancellation
Interviewers often want more than “fixed versus dynamic,” so mention the runtime behavior too:
async letworks well when the parent naturally needs every result. If one child throws when awaited, sibling child tasks are cancelled as the error propagates.- Task groups give you a place to express policy. With
withThrowingTaskGroup, you can surface the first error, cancel remaining work, or stop early once enough results have arrived. - In both cases, children are tied to the parent task. This is still structured concurrency, not detached background work.
What to say in an interview
Start with the shortest correct rule: async let is for a small fixed number of child tasks; task groups are for dynamic fan-out. Then add one sentence about behavior: task groups also give me more explicit control over how I collect results, handle partial success, or cancel leftover work.
Interview angle
Walk the answer in this order: fixed work known in the current scope means async let -> runtime-discovered work or as-completed consumption means task groups -> both are structured concurrency -> then mention error propagation, sibling cancellation, and whether the parent needs every result or only enough results to keep the UI responsive.