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 swift-actors. For error propagation, see 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.

// 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.

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.

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:

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 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.

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.

// 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.

// 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.