---
title: "Swift Async/Await: Structured Concurrency"
slug: "swift-async-await"
category: "coding"
tags: ["swift", "concurrency", "async-await", "structured-concurrency", "swift6"]
status: "stable"
last_updated: 2026-05-14
summary: "Async/await ergonomics, TaskGroup, cancellation, and bridging callback APIs with withCheckedContinuation in Swift 6."
related: ["[[coding/swift]]", "[[coding/swift-actors]]", "[[coding/swift-error-handling]]", "[[ios/swiftui]]", "[[ios/core-data]]"]
---

## Overview

Swift structured concurrency ties task lifetimes to the scope that creates them. A parent task waits for all children; cancellation propagates down the tree automatically. This page covers `async`/`await` syntax, `TaskGroup`, cancellation, and safe bridges to callback-based APIs. For isolation rules, see [[coding/swift-actors]]. For error propagation, see [[coding/swift-error-handling]].

## Mark functions `async` at the right level, not everywhere

Add `async` only to functions that genuinely suspend. Propagating `async` to every caller "because it might need it later" obscures which code actually suspends. A function is `async` when it calls `await`, uses an `async` property, or returns from an `actor` method.

```swift
// Only fetchData actually suspends; the formatter does not
func formatDate(_ date: Date) -> String {
    DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .none)
}

func loadProfile(id: UUID) async throws -> Profile {
    let data = try await api.fetch(endpoint: .profile(id))
    return try JSONDecoder().decode(Profile.self, from: data)
}
```

## Use `withThrowingTaskGroup` for bounded parallel work

Fan out multiple concurrent tasks and collect results with a `TaskGroup`. The group cancels remaining children if one throws, and the compiler ensures you cannot use the group after it returns.

```swift
func fetchImages(urls: [URL]) async throws -> [URL: UIImage] {
    try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in
        for url in urls {
            group.addTask {
                let (data, _) = try await URLSession.shared.data(from: url)
                guard let image = UIImage(data: data) else {
                    throw ImageError.decodeFailure(url)
                }
                return (url, image)
            }
        }
        var results: [URL: UIImage] = [:]
        for try await (url, image) in group {
            results[url] = image
        }
        return results
    }
}
```

Use `withTaskGroup` (non-throwing) when child tasks do not fail. Add tasks inside the `body` closure only; adding tasks after the closure returns is a compile error.

## Cancel tasks explicitly at lifecycle boundaries

Cancellation is cooperative. Check `Task.isCancelled` or call `try Task.checkCancellation()` at natural pause points inside long loops. Do not suppress cancellation silently.

```swift
func processItems(_ items: [Item]) async throws {
    for item in items {
        try Task.checkCancellation()
        try await process(item)
    }
}
```

Store a `Task` handle when you need to cancel from outside:

```swift
final class DownloadCoordinator {
    private var downloadTask: Task<Void, Error>?

    func start(url: URL) {
        downloadTask = Task {
            try await download(url: url)
        }
    }

    func cancel() {
        downloadTask?.cancel()
    }
}
```

In [[ios/swiftui]], attach work to a view with `.task { }` so the runtime cancels it automatically when the view disappears.

## Bridge callbacks with `withCheckedContinuation`

Use `withCheckedContinuation` to wrap a completion-handler API once. The "checked" variant crashes on double-resume or missing resume, which is the right behavior during development.

```swift
func legacyFetch(id: String) async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        OldNetworkClient.shared.fetch(id: id) { data, error in
            if let error {
                continuation.resume(throwing: error)
            } else if let data {
                continuation.resume(returning: data)
            } else {
                continuation.resume(throwing: FetchError.empty)
            }
        }
    }
}
```

The continuation must be resumed exactly once. If the legacy API can invoke its callback more than once, resume only on the first call and ignore subsequent ones with a flag. Switch to `withUnsafeContinuation` only after the logic is proven correct.

## Prefer `async let` over serial `await` for independent values

Two independent async calls in sequence are serial. `async let` starts both immediately and suspends only when the values are used.

```swift
// Serial: profile loads before avatar starts
let profile = try await api.fetchProfile(id: id)
let avatar = try await api.fetchAvatar(url: profile.avatarURL)

// Concurrent: both start together
async let profile = api.fetchProfile(id: id)
async let avatar = api.fetchAvatar(url: profileURL)
let (p, a) = try await (profile, avatar)
```

Use `async let` when two tasks are independent. Use `TaskGroup` when the number of tasks is dynamic or when you need to process results as they arrive.

## Avoid fire-and-forget `Task { }` inside business logic

`Task { ... }` creates an unstructured task that is not tied to the caller's scope. The task outlives its creator, cancellation does not propagate from parent to child, and errors disappear silently. Reserve `Task { }` for the narrow case where a synchronous context must start async work, such as a SwiftUI `Button` action.

```swift
// Bad: error is lost, lifecycle is unclear
func onAppear() {
    Task { try? await loadFeed() }
}

// Better: lifecycle is owned by the view
.task { try? await loadFeed() }
```

Inside actor methods, `async` functions, or anywhere an `await` is already legal, call async functions directly without wrapping them in `Task`.

## Related

- [[coding/swift]]
- [[coding/swift-actors]]
- [[coding/swift-error-handling]]
- [[coding/swift-value-types]]
- [[ios/swiftui]]
- [[ios/core-data]]
