What happens to an async let that you never await when the scope ends?

10.8.3 · L3 · Interview

Predict first

b is never awaited. What is printed?

import Foundation

func slow(_ name: String, _ ms: UInt64) async -> String {
    do { try await Task.sleep(nanoseconds: ms * 1_000_000); print(name, "finished"); return name }
    catch { print(name, "cancelled"); return "none" }
}
func scope() async {
    async let a = slow("a", 100)
    async let b = slow("b", 2000)
    print("awaiting only a")
    print(await a)
    print("leaving the scope without awaiting b")
}
await scope()
print("after scope")
  • awaiting only a / a finished / a / leaving the scope without awaiting b / b cancelled / after scope
  • awaiting only a / a finished / a / leaving the scope without awaiting b / after scope
  • awaiting only a / a finished / a / leaving the scope without awaiting b / after scope / b finished

Mental model. You hire two couriers for one errand and only collect one parcel. On the way out the office tells the second courier to stand down, and the door stays shut until they have.

async let starts a child task immediately, bound to the scope that declares it. If the scope exits without awaiting it (an early return, a throw, or simply forgetting), the compiler inserts an implicit cancel and await: the child is marked cancelled and the scope waits for it to finish before leaving. In the check, b (a two-second sleep) is cancelled at the end of scope() and prints “b cancelled” before “after scope”; the function does not return early and the child is never left running in the background.

Two consequences follow. First, an unawaited async let is a structured guarantee, unlike Task { }, which would keep running after the function returned. Second, if the child ignores cancellation, the scope blocks until it finishes. So use async let for a fixed, small number of concurrent steps whose results you need, and make sure each child cooperates with cancellation. If an awaited async let throws, the other unawaited ones are cancelled the same way. For a dynamic number of children use a task group.

The scope cancels b and waits for it before returning.

Common traps

  • Treating an unawaited async let as fire and forget. It is cancelled and awaited at scope exit.
  • Putting side effects in an async let that you expect to always complete. Cancellation can cut it short.

They might follow up with

  • How do you start work that outlives the scope? An unstructured Task { } or a Task.detached, accepting that you now own its lifetime and cancellation.
  • Can an async let be captured by an escaping closure? No; it belongs to the scope that declared it.

Related questions

  • 10.8.1 In what order do task group results arrive, and how do you limit how many children run at once?
  • 10.8.4 How does cancellation actually reach your code: what do checkCancellation, isCancelled…
  • 10.3.1 What does async/await actually do, and what is a suspension point?
  • 10.3.2 How do tasks form a tree: Task, task groups, async let and detached tasks, and what does a child inherit?

← What happens to the other children when one child in a throwing task…   How does cancellation actually reach your code: what do… →