Available for work
Ankit Ranjan
Back to Deep Dives

async/await and Actors — Structured Concurrency in Swift

Swift 5.5 retired the callback pyramid. async/await made asynchronous code read top to bottom, and actors turned data races from a runtime nightmare into a compile-time error.

June 4, 2026 7 topics 7 quiz questions
Share:
1

Life before async/await

Let's start with the pain, because every good language feature is an answer to a specific pain. Before Swift 5.5, asynchronous work meant completion handlers — you passed a closure, and the framework called it back when the work finished.

func loadProfile(completion: @escaping (Profile?, Error?) -> Void) {
    fetchUser { user, error in
        guard let user = user else { completion(nil, error); return }
        fetchPosts(for: user) { posts, error in
            guard let posts = posts else { completion(nil, error); return }
            fetchAvatar(for: user) { image, error in
                completion(Profile(user, posts, image), nil)
            }
        }
    }
}
Three dependent calls, and already the code marches off to the right. People christened this the pyramid of doom. And the deeper problem is not the indentation — it's the error handling. Every callback has its own error path, and forgetting one is silent. There's no compiler nagging you to handle the failure, because as far as the type system is concerned a closure that never gets called is perfectly fine.

The pyramid of doom vs flat async/awaitCompletion handlersfetchUser { user infetchPosts(user) { posts infetchImage(posts) { img inupdate(img) // 4 deep} // error path? skipped}}Nesting grows. Errors get forgotten.async / awaitlet user = await fetchUser()let posts = await fetch(user)let img = await image(posts)update(img)// errors propagate via// try / catch, like sync codeFlat. Linear. One error path.Same four steps, half the indentation, none of the forgotten error paths.

Underneath all of this sat Grand Central Dispatch, the dispatch-queue library Apple shipped in 2009 with Mac OS X 10.6 Snow Leopard. GCD gave us a clean way to push work onto background queues and hop back to the main queue for UI updates.

DispatchQueue.global().async {
    let data = expensiveWork()
    DispatchQueue.main.async {
        self.label.text = data   // hop back for UI
    }
}
GCD was a genuine leap forward in its day, but it was a library bolted onto the language, not part of it. The compiler couldn't see the control flow. It couldn't tell that the inner closure runs on a different queue, couldn't warn you about a captured variable being mutated from two queues, couldn't guarantee you eventually called completion exactly once. We needed the language itself to understand asynchrony. So in 2021, it learned.

2

async/await — code that reads top to bottom

Swift's answer arrived as SE-0296 and shipped in Swift 5.5 at WWDC 2021. The idea is small and the payoff is enormous. You mark a function async if it might need to wait, and at each waiting point you write await.

func fetchUser() async throws -> User { ... }

func loadProfile() async throws -> Profile {
    let user  = try await fetchUser()
    let posts = try await fetchPosts(for: user)
    let image = try await fetchAvatar(for: user)
    return Profile(user, posts, image)
}
That's the whole pyramid, flattened. It reads top to bottom like ordinary synchronous code, and errors flow through try / throws exactly the way they do everywhere else in Swift. One error path, and the compiler insists you account for it.

So what does await actually do? It marks a suspension point. When execution reaches an await, the function may pause — and here's the crucial bit — suspending frees the thread. It is not a block. The thread is handed back so it can run other work while this function waits. When the awaited operation completes, the function resumes, possibly on a different thread, and carries on from exactly where it left off.

// Each await is a place the function can pause and the thread can leave
let a = await stepOne()    // suspend, thread does other work, resume
let b = await stepTwo(a)   // suspend again
return b
A couple of rules fall out of this. You can only await from inside an async context, just as you can only try meaningfully where errors are handled. And to get from synchronous code into the async world — say from a button handler — you open a Task, which we'll meet properly in a moment. The mental shift is this: await is not "wait here and waste the thread", it's "this is a safe place to step aside".

3

Structured concurrency — child tasks that can't escape

Sequential awaits are easy to read, but sometimes the work is independent and we'd rather run it all at once. Swift's tool for that is structured concurrency, and the word "structured" is doing real work. It means concurrent tasks have a tree shape that mirrors your code's block structure: a child task cannot outlive the scope that created it.

The lightest tool is async let. It kicks off a child task immediately and binds a promise of the result; you await the binding only when you actually need the value.

func loadDashboard() async throws -> Dashboard {
    async let user = fetchUser()      // starts now
    async let feed = fetchFeed()      // starts now, concurrently
    async let ads  = fetchAds()       // starts now, concurrently
    // all three run at once; we suspend here until each is ready
    return Dashboard(try await user, try await feed, try await ads)
}
Structured concurrency: children cannot outlive the parentParent task — loadDashboard()opens a scope, then awaits its childrenasync let userchild runs concurrentlyfetchUser()async let feedchild runs concurrentlyfetchFeed()async let adschild runs concurrentlyfetchAds()await user, feed, ads — the parent only returns once every child has finished.If the parent is cancelled, cancellation flows down to all children automatically.No child task can leak past this scope. Lifetimes nest like brackets, so the compiler canreason about them. Use withTaskGroup when the number of children is dynamic.

When the number of children isn't known in advance — say you're fetching a list of URLs whose length is decided at runtime — you reach for a task group via withTaskGroup. You add as many child tasks as you like, then iterate their results as they arrive.

func fetchAll(_ urls: [URL]) async throws -> [Data] {
    try await withThrowingTaskGroup(of: Data.self) { group in
        for url in urls {
            group.addTask { try await download(url) }
        }
        var results: [Data] = []
        for try await data in group {   // results stream in as they finish
            results.append(data)
        }
        return results
    }
}
The guarantee that makes this safe is the scoping rule: when the withTaskGroup block returns, every child it spawned has finished. Children cannot leak out the back. This is why cancellation can be cooperative and tidy. Cancel the parent and the cancellation propagates to every child. But — and this matters — cancellation in Swift is a polite request, not a forced stop. A task keeps running until it checks. So long-running work should ask, either by reading Task.isCancelled or by calling try Task.checkCancellation(), which throws if the task has been cancelled.

for item in hugeList {
    try Task.checkCancellation()   // bail out promptly if cancelled
    process(item)
}

4

Actors — serialising access to mutable state

async/await tames the timeline. But the moment two tasks run concurrently, we face the oldest concurrency demon of all: the data race. Two tasks reading and writing the same mutable variable at the same time, producing torn values and bugs that vanish the instant you attach a debugger. Swift's answer is the actor, introduced in SE-0306.

An actor is a reference type — like a class — but with one defining superpower: it serialises access to its own mutable state. Only one task may be executing inside an actor's isolated code at any moment. Everyone else queues. The state is therefore protected without you ever writing a lock.

actor BankAccount {
    private var balance = 0

    func deposit(_ amount: Int) {
        balance += amount        // safe: only one task in here at a time
    }

    func currentBalance() -> Int { balance }
}
An actor serialises concurrent callersTasks calling inTask Aawait deposit()Task Bawait balance()Task Cawait withdraw()MailboxC waitingB waitingA runningone at a timeactor BankAccountProtected mutable statevar balance = 100only one task touches itCalls from outside the actor are awaited. The actor runs them one at a time,so two tasks can never mutate the balance simultaneously. No locks. No data races.Caveat: across a suspension inside an actor method, another task may run first (reentrancy).

Because access is serialised, calling into an actor from outside might mean waiting your turn — so those calls are await-ed. This is actor isolation: from elsewhere you cross a boundary, and crossing it is a potential suspension point.

let account = BankAccount()
await account.deposit(100)               // cross the boundary
let total = await account.currentBalance()   // await your turn
// account.balance                        // error: balance is isolated
Now the subtlety that trips everyone up: actors are reentrant. If an actor method itself contains an await, the actor suspends that call and is free to run another queued call in the meantime. So state you observed before an await may have changed by the time you resume. The lesson: never assume an actor's state is unchanged across a suspension point. Re-check invariants after every await inside actor code, just as you would after yielding anywhere else.

5

Sendable, @MainActor, and Swift 6

Actors protect the state they own. But what about values that travel between concurrency domains — a struct you hand to a task, a result you send back? Swift needs a way to know which types are safe to cross those boundaries. That marker is the Sendable protocol.

A Sendable type is one that can be passed between concurrency domains without introducing a data race. Value types made entirely of Sendable parts — most structs and enums — are Sendable automatically. A class is trickier, because two references can point at the same mutable instance; a class is only Sendable if it's immutable, or an actor, or you've taken responsibility yourself with @unchecked Sendable.

struct Point: Sendable { var x: Int; var y: Int }   // fine: value type

final class Counter {            // NOT Sendable: mutable shared state
    var value = 0
}
The other half of the story is the main thread. UI work must happen there, full stop. Swift expresses that with @MainActor, a global actor that pins whatever it annotates to the main thread.

@MainActor
final class ViewModel: ObservableObject {
    @Published var title = ""

    func refresh() async {
        let text = await loadTitle()   // off the main actor
        title = text                   // back on main, safely
    }
}
For years all of this was advisory. The change came with the Swift 6 language mode, released in 2024, which turns on strict concurrency checking. Send a non-Sendable value across an actor boundary, touch a UI property off the main actor, share mutable state without protection — and the compiler now refuses to build. Many data races that used to be runtime ghosts became plain old compile errors. It is stricter, occasionally noisier, and a great deal safer.

6

Bridging the old world with continuations

The async world is lovely, but the world is full of older APIs that still speak callbacks — delegate methods, completion handlers, C libraries. We need a bridge from "call me back later" to "await me". That bridge is a continuation.

withCheckedContinuation (and its throwing sibling withCheckedThrowingContinuation) hands you a continuation object and suspends. You stash that object, call the old API, and when its callback fires you resume the continuation with the result. The suspended await wakes up with that value.

func loadImage(named name: String) async throws -> UIImage {
    try await withCheckedThrowingContinuation { continuation in
        legacyLoadImage(name) { image, error in
            if let image {
                continuation.resume(returning: image)
            } else {
                continuation.resume(throwing: error ?? LoadError.unknown)
            }
        }
    }
}
Now the caller just writes let image = try await loadImage(named: "logo") and never sees the callback at all.

Why the word checked? Because a continuation carries one ironclad rule: it must be resumed exactly once. Resume it zero times and the awaiting task hangs forever — a leak. Resume it twice and you've corrupted the runtime. These are classic callback bugs, easy to write and miserable to find. The checked variants police the contract at runtime: if a checked continuation is resumed more than once it traps, and if it's discarded without ever being resumed it logs a warning. There's a faster withUnsafeContinuation that skips the checks, but reach for it only once you're certain the logic is correct.

7

The runtime, and how it differs from Dart

Let's lift the hood. A natural worry is that spawning thousands of tasks spawns thousands of threads. It does not. Swift tasks run on a cooperative thread pool — a small number of threads, typically about one per CPU core, not one OS thread per task.

One thread per task vs a cooperative poolThread per taskthreadT1threadT2threadT3thread...T10001000 tasks could mean 1000 threads.Each thread costs memory and a kernelstack. Switching between them isexpensive: thread explosion.A blocked thread is just dead weight.The old GCD world overcommitted threads.Cooperative poolcore threadT1core threadT2core threadT3, T4A small pool, roughly one thread perCPU core. Thousands of tasks share it.A task suspends at await and hands itsthread to the next ready task.Threads are never blocked on awaits.This is why we must not block a pool thread.

The pool works because, as we saw earlier, suspension is not blocking. When a task hits an await, it stores its continuation and gives the thread straight back to the pool, which immediately picks up the next ready task. A handful of threads thus serve an enormous number of in-flight tasks.

await suspends — it frees the thread, it does not blockOne thread, over timeTask A runsup to the awaitawaitTask B runs on the same threadA is suspended, costing nothingTask A resumesafter the await completesContrast: a blocking callTask A runsthen blocksthread sits idle, holding the waitnobody else can use itA continuesthread wastedA suspension stores the task's continuation and hands the thread back to the pool.A few threads can therefore serve thousands of in-flight tasks.

This is also the one trap to remember: never call a genuinely blocking function (a synchronous network read, a long sleep) on a pool thread. That thread can't be reclaimed, and a pool with only a few threads can grind to a halt. Yield with await, or move the blocking work elsewhere.

It's worth contrasting this with Dart, which we covered in the isolates episode, because the two languages chose opposite philosophies. Dart isolates share no memory whatsoever — each has its own heap, and they communicate only by passing copies of messages. That design makes data races structurally impossible: there's nothing shared to race over. Swift took the other road. Swift tasks and actors share memory, which is fast and convenient, and then it guards that shared memory with actors (serialising access) and Sendable (policing what may cross boundaries). Two reasonable answers to the same hard problem: Dart removes the sharing; Swift keeps it and fences it. Understanding which model you're in tells you exactly what kinds of bug you'll never have to chase.

Test your understanding

7 questions

Seven questions covering async/await, structured concurrency, actors, Sendable, and the runtime.

Search

Loading search...