Skip to content

Async and await, tasks, groups, cancellation, and continuations

An asynchronous function can pause without blocking the thread that began it. Swift marks that possibility with async, and a caller marks each suspension point with await. Those keywords describe control flow. Tasks provide the lifetime, priority, cancellation, and result container around that work.

Structured concurrency keeps child work inside a lexical scope. The parent cannot finish until its child tasks finish or are cancelled. This gives asynchronous work an ownership tree instead of a loose collection of callbacks.

Suspension is not a new thread

await means the function may suspend at that expression. The runtime can use the underlying thread for other work, then resume the task later. Code after an await must not assume it runs on the same thread.

func loadMetadata() async throws -> NoteAsset {
try await Task.sleep(for: .milliseconds(10))
try Task.checkCancellation()
return NoteAsset(kind: "metadata", value: "Tide Pools")
}

The type states both effects: the function can suspend and can throw. Callers must acknowledge both with try await.

Tasks give work a lifetime

A Task starts asynchronous work and returns a handle. The handle exposes its eventual value and supports cancellation. A task created only to escape the current structure needs a documented owner and cancellation point.

Use ordinary async calls when the operations are sequential. Use async let for a fixed small set of child operations. Use a task group when the number of children is dynamic or when results should be collected as they finish.

ShapeBest fit
sequential awaitnext operation depends on previous result
async letfixed independent child operations
task groupdynamic fan-out or completion-order collection
unstructured Taskwork needs a lifetime outside the current async function
detached taskrare work that must not inherit task context

Load assets with a throwing task group

Field Notes needs metadata and a photo before it can present a complete note. The checkpoint adds both operations to a throwing group:

try await withThrowingTaskGroup(of: NoteAsset.self) { group in
group.addTask { try await loadMetadata() }
group.addTask { try await loadPhoto() }
var assets: [NoteAsset] = []
for try await asset in group {
assets.append(asset)
}
return assets
}

The loop receives results in completion order, not submission order. The example sorts before asserting so its output stays deterministic. If a child throws, the group propagates the error and cancels unfinished siblings before leaving the scope.

Cancellation is cooperative

cancel() records a request. It does not forcibly stop arbitrary Swift instructions. Suspending APIs such as Task.sleep often throw CancellationError. CPU-bound loops and custom operations need explicit checks:

try Task.checkCancellation()

Use Task.isCancelled when cleanup or a partial result is appropriate. Use checkCancellation() when the operation should stop through its throwing contract.

Cancellation is part of product behavior. Leaving a screen, replacing a search query, or starting a newer sync can make older work irrelevant. Decide whether cancellation preserves local edits, discards partial output, or records a resumable checkpoint.

Continuations bridge callbacks

A checked continuation turns one completion-based operation into an async function:

func bridgedTitle() async throws -> String {
try await withCheckedThrowingContinuation { continuation in
legacyTitle { result in
continuation.resume(with: result)
}
}
}

The callback must resume the continuation exactly once on every path. Never resuming leaves the awaiting task suspended. Resuming twice violates the continuation contract. Checked continuations diagnose misuse during development, but the design still needs a clear one-shot callback contract.

Do not wrap an already async API in a continuation. The bridge belongs at the legacy boundary, then the rest of the application can remain async.

Run the checkpoint

idle

Execution sends this source to the project runner. It uses Swift 6.3.3 on Linux for standard-library code, not the Apple SDK, an iOS simulator, or a device.

Edit the source, then choose Run Swift. If no runner is configured, the source stays in this editor.

Compiler diagnostics

(none)

Standard output

(no stdout)

Standard error

(no stderr)

Expected output:

Assets: metadata, photo
Continuation title: Forest Light
Cancellation observed: true

The source uses the Swift standard library. It proves async functions, a throwing task group, deterministic result collection, checked continuation bridging, task handles, and cooperative cancellation under the runner boundary. It does not prove URLSession, Photos, SwiftUI task lifetime, background execution, MainActor behavior, Simulator scheduling, or device energy use.

The unstructured Task trap

Wrapping every callback in Task { ... } hides rather than solves lifetime design. Ask:

  • Who stores the task handle?
  • What event cancels it?
  • Does it inherit actor context and priority intentionally?
  • Can a newer request supersede it?
  • Where does its thrown error go?

Prefer child tasks whose lifetime is bounded by the function doing the work. Reach for an unstructured task when a UI or service owner genuinely needs to start and later cancel work.

Check your understanding

You should now be able to explain:

  • How suspension differs from blocking a thread.
  • Why a task group cannot leak child work beyond its scope.
  • Why task-group results need ordering when output must be deterministic.
  • Why cancellation is a request rather than forced termination.
  • Which exact-once rule makes a continuation safe.

The next post isolates shared sync bookkeeping in an actor, then separates that domain boundary from UI work on the main actor.

Series navigation

References