Learning Swift Concurrency

A comprehensive, hands-on guide to async/await, structured concurrency, actors, Sendable, and the rest of Swift's modern concurrency model. This guide goes deep — from the motivation behind the design to the corners that trip up experienced engineers — with iOS-flavored examples throughout.

Table of Contents

  1. Why Concurrency Exists, and Why Swift Concurrency
  2. The World Before async/await
  3. Your First async Function
  4. await: Suspension Points in Depth
  5. Throwing Async Functions
  6. Calling Async Code from Sync Contexts
  7. Tasks: The Unit of Concurrency
  8. Structured Concurrency: The Core Principle
  9. async let — Lightweight Concurrency
  10. Task Groups
  11. Cancellation Throughout the System
  12. Task Priorities
  13. Task Local Values
  14. Continuations: Bridging Callback APIs
  15. AsyncSequence
  16. AsyncStream and AsyncThrowingStream
  17. swift-async-algorithms
  18. Actors: The Shared State Solution
  19. Actor Isolation in Detail
  20. Reentrancy: The Subtle Trap
  21. nonisolated and Escape Hatches
  22. @MainActor and Global Actors
  23. Sendable and the Type System
  24. @Sendable Closures
  25. Strict Concurrency and Swift 6
  26. Migrating from GCD and Completion Handlers
  27. Migrating from Combine
  28. iOS-Specific Patterns
  29. Testing, Debugging, and Performance
  30. Common Patterns, Anti-patterns, and Where to Go Next

1. Why Concurrency Exists, and Why Swift Concurrency

Before we write a single line of code, it's worth being clear about what concurrency is, what problem it solves, and why Swift adopted such a specific model when other languages already had async/await.

The fundamental problem

Computers do two kinds of work: computation and waiting. Computation means actually executing instructions — adding numbers, running loops, transforming data. Waiting means asking some other system to do something — read a file, fetch a web page, wait for a user to tap a button — and twiddling thumbs until the answer comes back.

If your program does these things sequentially, every wait blocks the entire program. Imagine an iOS app that fetches a photo from the network. If that fetch takes 800ms and you do it on the main thread, your UI freezes for 800ms. Buttons don't respond. Animations stutter. The user thinks the app crashed.

The whole point of concurrency is to make waiting productive. While one piece of work is waiting on the network, another piece can be running. Your UI thread keeps drawing frames. Your data layer keeps processing. The CPU stays busy.

Concurrency vs parallelism

These two terms are often confused. They mean related but distinct things:

A concurrent program may or may not run in parallel. An iOS app might have ten concurrent network requests, but they're all waiting — the CPU is barely doing anything. That's concurrency without parallelism. A scientific computation that splits a matrix multiplication across eight cores is parallelism.

Swift's concurrency model handles both, but the language design is overwhelmingly oriented around the first case: I/O-bound, latency-driven workloads where the goal is responsiveness, not raw throughput.

Why a new model?

Swift had concurrency before async/await — Grand Central Dispatch (GCD), OperationQueue, completion handlers, DispatchSemaphore, NSLock, Combine. None of this went away. So why add another layer?

The honest answer: every existing approach had compounding problems that the language couldn't help with. Some of them:

Completion handlers create the pyramid of doom. Sequential async work nests deeper and deeper:

func loadProfile(userID: String,
                 completion: @escaping (Result<Profile, Error>) -> Void) {
    fetchUser(id: userID) { result in
        switch result {
        case .failure(let error): completion(.failure(error))
        case .success(let user):
            fetchAvatar(url: user.avatarURL) { result in
                switch result {
                case .failure(let error): completion(.failure(error))
                case .success(let avatar):
                    fetchPosts(for: user.id) { result in
                        switch result {
                        case .failure(let error): completion(.failure(error))
                        case .success(let posts):
                            completion(.success(Profile(user: user,
                                                         avatar: avatar,
                                                         posts: posts)))
                        }
                    }
                }
            }
        }
    }
}

Each level adds error handling that has to be threaded through manually. Forget one branch, and the completion handler is never called — your caller waits forever. This is unbelievably common in real code.

GCD threading is invisible to the type system. A function that hops to a background queue and calls back has no signature distinguishing it from one that doesn't. A class meant to be used from a single queue has no way to tell the compiler that. Every misuse becomes a runtime bug.

Cancellation is bolted on. GCD has DispatchWorkItem.cancel(), but cancellation doesn't propagate. If you start three queue tasks and want to cancel them all when the user navigates away, you have to wire it manually. Combine has cancel(), but composing cancellation across multiple chains is fiddly.

Data races are invisible until they bite. Two queues touching the same Array is a race. There's no compile-time check. You ship it, and weeks later a customer has a crash log you can't reproduce.

Swift Concurrency was designed to fix these, all at once, with type-system support. The features we'll cover in this guide aren't independent — they fit together as a single coherent model.

The four pillars

Swift Concurrency rests on four ideas that the rest of this guide explores:

  1. async/await — pause and resume execution at well-defined points, so async code reads like sync code.
  2. Structured concurrency — child tasks have lifetimes bounded by their parents; cancellation, errors, and resources flow through a clear hierarchy.
  3. Actors — types whose mutable state can only be touched by one task at a time, automatically.
  4. Sendable — a compile-time check that values passed between concurrent contexts can't cause data races.

Each of these on its own is useful. Together they let the compiler catch entire categories of bugs that used to be runtime mysteries. Swift 6 turns the warnings into errors, making correctness the default.

The mental model to start with

Before we get into syntax, hold this picture in your head:

Everything else is detail. We'll fill in the detail systematically — starting with what life looked like before, so you appreciate what async/await actually changed.


2. The World Before async/await

You can write modern Swift Concurrency without ever having seen GCD or Combine, but the existing world isn't going away. You'll encounter these APIs in every real codebase. You'll bridge old code into new code. You'll need to understand why a callback-based API is harder to compose than an async one. So a brief tour.

NSThread and POSIX threads

The lowest level. You can spawn a thread directly:

let thread = Thread {
    print("hello from thread")
}
thread.start()

Nobody writes code at this level anymore on Apple platforms — you'd have to manage the thread's lifecycle, synchronization, communication, all by hand. But it's worth knowing it exists, because every higher-level mechanism eventually maps to threads underneath.

Grand Central Dispatch (GCD)

GCD is Apple's libdispatch — a C library exposed to Swift as the Dispatch module. It introduced queues as the unit of work, abstracting threads. You hand a closure to a queue, and GCD finds a thread to run it on.

import Dispatch

DispatchQueue.global(qos: .userInitiated).async {
    let data = expensiveComputation()
    DispatchQueue.main.async {
        updateUI(with: data)
    }
}

Two queues here: a global background queue for the work, and DispatchQueue.main for the UI update. The pattern of "do work in background, hop to main to update UI" was for years the bread-and-butter of iOS development.

GCD has serial queues (one task at a time) and concurrent queues (many at once), and you can build your own:

let myQueue = DispatchQueue(label: "com.example.work")
myQueue.async { /* serial */ }

let parallelQueue = DispatchQueue(label: "com.example.parallel",
                                  attributes: .concurrent)
parallelQueue.async { /* one of many */ }

Serial queues were the standard way to make a class thread-safe before actors:

final class Counter {
    private let queue = DispatchQueue(label: "counter")
    private var _value = 0

    func increment() {
        queue.async { self._value += 1 }
    }

    func value(completion: @escaping (Int) -> Void) {
        queue.async { completion(self._value) }
    }
}

This works, but compare it to an actor (chapter 18) — it's verbose, the API is callback-based, and there's no compiler help if you accidentally read _value directly from outside.

DispatchSemaphore and synchronization primitives

GCD exposes semaphores, locks, barriers — the classics:

let lock = NSLock()
var shared: [String] = []

queue1.async {
    lock.lock(); defer { lock.unlock() }
    shared.append("a")
}

queue2.async {
    lock.lock(); defer { lock.unlock() }
    shared.append("b")
}

Forget the lock(), or forget the unlock(), or hold the lock across an await somewhere down the chain — you've got a deadlock or a race. The compiler can't help.

OperationQueue and Operation

A higher-level API on top of GCD that adds dependencies, priorities, and cancellation as first-class concepts:

let queue = OperationQueue()
let opA = BlockOperation { print("A") }
let opB = BlockOperation { print("B") }
opB.addDependency(opA)
queue.addOperations([opA, opB], waitUntilFinished: false)

Operations were the precursor to structured concurrency in spirit — dependencies form a DAG, cancellation can propagate, you can subclass Operation to encapsulate complex async work. They're still useful for image-loading pipelines and similar coordination, but most code has moved on.

Completion handlers

The dominant pattern of pre-2021 Swift. Functions take a closure to call when they finish:

func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
    URLSession.shared.dataTask(with: makeURL(id)) { data, response, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        guard let data = data else {
            completion(.failure(MyError.noData))
            return
        }
        do {
            let user = try JSONDecoder().decode(User.self, from: data)
            completion(.success(user))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}

The pyramid-of-doom problem we saw earlier comes from sequencing these. The other big problem: it's easy to forget to call the completion handler in some branch. The function silently never finishes, and the caller hangs forever. There's no compiler check.

Combine

Apple's reactive framework, introduced in iOS 13. Values flow through pipelines of operators:

import Combine

var cancellables: Set<AnyCancellable> = []

URLSession.shared.dataTaskPublisher(for: url)
    .map(\.data)
    .decode(type: User.self, decoder: JSONDecoder())
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { completion in
        if case .failure(let error) = completion {
            print("error: \(error)")
        }
    }, receiveValue: { user in
        self.user = user
    })
    .store(in: &cancellables)

Combine is genuinely useful for stream-shaped problems — UI bindings, debouncing, throttling, combining multiple async sources. But for "fetch one thing, then do something with it," it's overkill, and the Cancellable storage discipline is its own form of bookkeeping.

Combine still exists and isn't deprecated. SwiftUI uses it under the hood for @Published and ObservableObject. But for new async work, async/await is what you reach for.

Why all of this needed replacing

Look at what these APIs share. Every one of them:

Swift Concurrency unifies all of these. Async functions read top-to-bottom. Errors use try/throw. Cancellation propagates through a tree of tasks automatically. The compiler enforces thread safety. You'll still see GCD and Combine in real code, and you'll bridge them when you have to (chapters 14, 26, 27), but you won't reach for them first anymore.


3. Your First async Function

Let's get our hands on the keyboard. Two new keywords: async marks a function that can suspend, and await marks the points where suspension might happen.

The simplest example

func sayHello() async -> String {
    return "hello"
}

async goes after the parameter list and before the return arrow. This function is async, but it doesn't actually suspend anywhere — it just returns a value. That's legal. An async function is allowed to be able to suspend, not required to actually do it.

To call it:

let greeting = await sayHello()
print(greeting)

That's it. await says "this call might suspend; I'm prepared for that." The compiler requires await at every call to an async function. You can't accidentally call async code without acknowledging it.

But there's a catch: where does this await happen? You can't write it at the top level of a file in most contexts, and you can't write it inside a regular synchronous function. await is only legal inside another async function, or inside a Task { } block, or in a few other async contexts (like @main or top-level scripts). We'll get to how to bridge sync→async in chapter 6.

A more realistic example

A function that does actual asynchronous work — fetching JSON from an endpoint:

func fetchUserName(id: String) async throws -> String {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    let user = try JSONDecoder().decode(User.self, from: data)
    return user.name
}

Read this top-to-bottom. Build a URL. Fetch data — this is the suspension point, marked with await and try (it can also throw). Decode. Return. No completion handler, no nesting, no Result type. The error handling uses normal try/throw. The async control flow is invisible — you write it as if it were sync, and the compiler handles the suspension.

Call it:

do {
    let name = try await fetchUserName(id: "42")
    print(name)
} catch {
    print("failed: \(error)")
}

This is structurally identical to synchronous error-handling code. Only the await keyword tips you off that there's any concurrency happening at all.

What the keywords actually do

Let's be precise about the language semantics, because the casual mental model can mislead you.

async is a function modifier. An async function has a different type than a non-async one — () -> Int and () async -> Int are different types. Async functions can only be called from async contexts. The compiler tracks this through every layer.

await marks a potential suspension point. It's not a guarantee that the function will suspend — many async calls run to completion without ever yielding the thread. The keyword is a marker for the reader and the compiler: "the world might change between here and the next line, because the runtime could run other tasks before resuming this one."

That's the most important consequence of await: across an await, anything could have happened. Any other task on the same actor could have run. State you read before could have changed. We'll come back to this when we get to actor reentrancy (chapter 20).

Multiple awaits in sequence

You can chain async calls naturally:

func loadProfile(userID: String) async throws -> Profile {
    let user = try await fetchUser(id: userID)
    let avatar = try await fetchAvatar(url: user.avatarURL)
    let posts = try await fetchPosts(for: user.id)
    return Profile(user: user, avatar: avatar, posts: posts)
}

Compare to the pyramid-of-doom version from chapter 1. Same logic, written linearly. The errors propagate via try. The function is async, so it returns its value the way a sync function does.

But notice: these three fetches happen sequentially. We await fetchUser, then fetchAvatar, then fetchPosts. If they don't depend on each other, we're wasting time. If each takes 200ms, the whole thing takes 600ms when it could take 200. We'll fix that in chapter 9 with async let.

Async properties and initializers

Properties can be async too:

extension User {
    var avatar: UIImage {
        get async throws {
            let (data, _) = try await URLSession.shared.data(from: avatarURL)
            return UIImage(data: data) ?? UIImage()
        }
    }
}

let img = try await user.avatar

Initializers can be async:

final class ImageLoader {
    let cache: [URL: Data]

    init(prefetching urls: [URL]) async throws {
        var cache: [URL: Data] = [:]
        for url in urls {
            let (data, _) = try await URLSession.shared.data(from: url)
            cache[url] = data
        }
        self.cache = cache
    }
}

let loader = try await ImageLoader(prefetching: [url1, url2])

Subscripts can be async too. The general rule: anywhere you can write a function or computed accessor, you can mark it async if it might suspend.

The compiler's contract with you

Three things to internalize:

  1. You can't call async from sync code without bridging through Task or similar.
  2. Every call to an async function inside another async function needs await.
  3. await doesn't mean "suspend"; it means "this point is allowed to suspend."

The fourth thing — the most important thing — comes from the third: state can change across an await. If you read a value before an await, don't trust that it's still that value after. We'll be saying this repeatedly. It's the heart of why concurrent code is hard, and the heart of why actors and reentrancy matter.


4. await: Suspension Points in Depth

Let's spend a chapter on await, because most concurrency bugs are misunderstandings of what it actually does.

What happens when a task suspends

A task (we'll formalize this in chapter 7) is the unit of concurrent work. Think of it as a lightweight thread, but with much less overhead — Swift can have thousands of tasks where you'd never have thousands of threads.

When a task hits an await and the operation behind it isn't ready yet (the network response hasn't come, the file isn't read, the actor it's calling is busy), the task suspends. Suspending means:

This is why async functions are so cheap: an idle async function doesn't consume a thread. Ten thousand awaiting network requests is fine. Ten thousand blocked threads would crush the system.

The thread isn't yours

Here's the consequence that surprises people coming from threads:

func doStuff() async {
    let beforeID = Thread.current
    await Task.sleep(for: .seconds(1))
    let afterID = Thread.current
    // beforeID may not equal afterID
}

After a suspension, you might be on a different thread. The runtime is free to resume your task wherever it likes. Don't tie your code to a specific thread unless you've put it on an actor (which is the right way to express that requirement — chapter 22).

This is also why pthread_self() checks and thread-local storage from the C world don't work the way you'd expect with Swift Concurrency. The "current thread" is an implementation detail, not part of the model.

Sequential by default

Read this carefully:

let a = await fetchA()    // takes 200ms
let b = await fetchB()    // takes 200ms
let c = await fetchC()    // takes 200ms
// total: 600ms

Each await waits for its operation to complete before the next line runs. They run sequentially, even though they could run concurrently if they don't depend on each other. The async function is still a linear sequence of statements.

This is good. It means you read async code top-to-bottom like sync code. Concurrency only happens when you explicitly ask for it (chapter 9 and 10).

Multiple awaits on one line

You can have several awaits in a single expression:

let combined = await fetchA() + (await fetchB())

The compiler evaluates left to right, so this is the same as:

let aVal = await fetchA()
let bVal = await fetchB()
let combined = aVal + bVal

You only need to write await once per statement even if there are multiple async calls — the keyword applies to the whole expression. So this is also legal:

let combined = await (fetchA() + fetchB())

Many style guides prefer separating them into multiple lines for clarity.

try await and await try

Both are legal but the convention is try await:

let user = try await fetchUser(id: "42")

It reads as "try to wait for fetchUser." The reverse order, await try, is legal but unusual. Pick one and be consistent — most codebases use try await.

If a function is async but doesn't throw, you only need await. If it throws but isn't async, you only need try. If both, both:

func sync() throws -> Int { ... }       // try only
func asyncNoThrow() async -> Int { ... } // await only
func asyncThrow() async throws -> Int { ... } // try await

Suspension is voluntary

A function marked async is able to suspend, but doesn't have to. Some async functions never actually yield — they just have async signatures because they call into other async code that might. The compiler conservatively requires await, but at runtime, control may not actually leave your function.

This matters for performance: an async function that never suspends has only modest overhead. The "fast path" through async code is reasonably efficient.

Where await is allowed

You can write await only inside an async context:

Trying to write await from a regular sync function is a compile error: "'async' call in a function that does not support concurrency." We'll cover bridging in chapter 6.

What the runtime does under the hood

You don't need to know this to use Swift Concurrency, but it helps demystify the magic.

When the compiler sees an async function, it transforms it into a state machine. Each await becomes a potential exit point. The state machine keeps track of "where am I in this function," "what local variables do I hold," and "what should I do when I resume."

When you call an async function, the runtime allocates a small structure (the task's continuation) holding this state. When the function suspends, control returns to the scheduler. When the awaited thing finishes, the scheduler puts the task back into the run queue. A worker thread picks it up and resumes the state machine from where it left off.

The "cooperative thread pool" Swift Concurrency uses has, by default, one thread per CPU core. Tasks share these threads — they're not 1:1 with kernel threads. This is why blocking inside an async task is so destructive: you'd be holding a precious thread that could otherwise serve thousands of concurrent tasks.

What you should never do inside an async task

Don't block. Specifically:

// BAD: blocks the cooperative thread
func wrong() async -> Data {
    let semaphore = DispatchSemaphore(value: 0)
    var result: Data = Data()
    URLSession.shared.dataTask(with: url) { data, _, _ in
        result = data!
        semaphore.signal()
    }.resume()
    semaphore.wait()    // <-- blocks!
    return result
}

This blocks one of the worker threads in the cooperative pool. If enough tasks do this, you starve the pool — pending async work that would have unblocked the semaphore can't run because all threads are stuck waiting. You can deadlock the entire runtime.

The right way is to bridge the callback API into async (chapter 14), or use URLSession's built-in async API: try await URLSession.shared.data(from: url).

Other things to never do in an async task: hold a DispatchQueue.sync, call Thread.sleep, call any pthread blocking primitive. If you need to wait, await. If you need to delay, try await Task.sleep(...).


5. Throwing Async Functions

We touched on this in chapter 3. Async functions can throw. The combined keyword is async throws, and you call them with try await:

func decodeUserFromServer(id: String) async throws -> User {
    let url = makeURL(id: id)
    let (data, response) = try await URLSession.shared.data(from: url)
    guard let httpResponse = response as? HTTPURLResponse,
          (200..<300).contains(httpResponse.statusCode) else {
        throw NetworkError.badStatus
    }
    return try JSONDecoder().decode(User.self, from: data)
}

Errors flow naturally:

do {
    let user = try await decodeUserFromServer(id: "42")
    print(user.name)
} catch NetworkError.badStatus {
    print("server returned an error")
} catch is DecodingError {
    print("response shape was unexpected")
} catch {
    print("other failure: \(error)")
}

This is the same do/catch/throw you'd write in sync code. No Result types. No completion-handler-with-Result-parameter. The error system is unified.

Typed throws (Swift 6)

Swift 6 added typed throws — you can declare which error type a function can throw:

enum FetchError: Error {
    case notFound
    case network(URLError)
}

func fetch(id: String) async throws(FetchError) -> Data {
    // ...
}

When you call this, the catch can narrow:

do {
    let data = try await fetch(id: "42")
} catch .notFound {
    // FetchError.notFound (the enum case is inferred)
} catch .network(let urlError) {
    // ...
}

Typed throws are useful but not always the right call — if the function might evolve to throw new error kinds, untyped throws is more flexible. Apple's frameworks generally still use untyped throws.

Rethrows in async context

rethrows works with async, the same as it does in sync code:

func map<T, U>(_ items: [T], _ transform: (T) async throws -> U) async rethrows -> [U] {
    var result: [U] = []
    for item in items {
        let mapped = try await transform(item)
        result.append(mapped)
    }
    return result
}

This function is async rethrows — it throws if and only if the closure throws. If you pass a non-throwing closure, the call site doesn't need try:

let lengths = await map(["a", "ab", "abc"]) { $0.count }      // no try
let parsed = try await map(strings) { try parse($0) }         // try needed

Cancellation and errors

When a task is cancelled (chapter 11), Swift expresses this by throwing CancellationError. Code that wants to be cooperatively cancellable should call try Task.checkCancellation() periodically:

func longComputation() async throws -> [Int] {
    var result: [Int] = []
    for i in 0..<1_000_000 {
        try Task.checkCancellation()  // throws CancellationError if cancelled
        result.append(expensiveStep(i))
    }
    return result
}

If the task running this is cancelled, checkCancellation throws CancellationError, the function unwinds, and the caller's do/catch can detect it:

do {
    let result = try await longComputation()
} catch is CancellationError {
    print("cancelled")
}

We'll see more cancellation patterns in chapter 11.

What about try? and try!

Both work with async:

let user = try? await fetchUser(id: "42")        // Optional<User>
let definitelyUser = try! await fetchUser(id: "42")  // crash on error

Same semantics as sync. try? turns a throw into nil. try! traps. Use try! only when you're absolutely certain the call can't fail in this context, and document why.

One subtle thing: void async throws

You can have async throwing functions that return Void:

func saveProfile(_ profile: Profile) async throws {
    let data = try JSONEncoder().encode(profile)
    try await persistData(data)
}

try await saveProfile(myProfile)

No return value, just side effects. Common pattern.


6. Calling Async Code from Sync Contexts

Async functions can only be called from async contexts. But your application starts in a sync context — main(), view-controller methods, button-tap handlers, SwiftUI view bodies. How do you bridge?

Task { } — the most common bridge

A Task { ... } block creates a new top-level task. You can call async code inside it. It returns immediately to the caller — the work continues independently.

button.addTarget(self, action: #selector(didTap), for: .touchUpInside)

@objc func didTap() {
    Task {
        let user = try? await fetchUser(id: "42")
        await MainActor.run {
            self.label.text = user?.name
        }
    }
}

Reading this top to bottom: the user taps a button. The sync handler didTap runs. Inside, we kick off a Task. The handler returns immediately. The task runs, awaits the fetch, hops to the main actor, updates the label.

Task { } inherits some context from where it's created — the actor (often MainActor if you're inside one), the priority, task-local values. We'll dig into this in chapter 7.

Task.detached { } — no inheritance

Sometimes you don't want to inherit context. Task.detached creates a task that doesn't inherit the current actor or task-local values:

Task.detached(priority: .background) {
    let report = await generateAnnualReport()
    await archive(report)
}

Use this when you genuinely want isolation from the calling context. Most of the time, plain Task { } is what you want.

@main and async entry points

Your app's entry point can be async. With @main:

@main
struct MyApp {
    static func main() async throws {
        let server = try await Server.start(port: 8080)
        try await server.serveForever()
    }
}

async throws on main() is allowed. The whole program is then driven by an async function from the start.

In SwiftUI:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

The body isn't async, but inside views you have plenty of ways to kick off async work — .task { } modifiers (chapter 28), Task { } blocks, etc.

MainActor.assumeIsolated

A new mechanism (Swift 5.9) for asserting at runtime that you're already on the main actor:

@MainActor
func updateUI() { /* ... */ }

func someUnknownContext() {
    MainActor.assumeIsolated {
        updateUI()  // OK because we asserted
    }
}

This is for callbacks from frameworks that you know deliver on the main thread but don't have an @MainActor annotation on their callback type. Use sparingly — it's a runtime check, not a compile-time one.

Synchronous waiting for async results — don't

It is technically possible to use a DispatchSemaphore to block a thread until an async task completes. Don't. As we discussed in chapter 4, blocking a cooperative thread is destructive. If you find yourself wanting "just give me the value synchronously," you almost certainly need to make the surrounding context async too.

The one legitimate exception is in tests, and even there you should prefer XCTest's async test support (chapter 29).

Common pattern: fire-and-forget with error logging

A standard idiom:

func didTap() {
    Task {
        do {
            try await saveDocument()
        } catch {
            logger.error("save failed: \(error)")
            await showAlert("Could not save")
        }
    }
}

The task is fire-and-forget — we don't await it from didTap. But we still handle errors inside. Without the do/catch, an error would propagate out of the task body and disappear silently.

Storing the task to allow cancellation

If you want to cancel the task later (the user navigates away, taps cancel, etc.), store it:

private var loadTask: Task<Void, Never>?

func didTap() {
    loadTask?.cancel()  // cancel any previous one
    loadTask = Task {
        do {
            try await loadEverything()
        } catch {
            // handle
        }
    }
}

deinit {
    loadTask?.cancel()
}

A Task<Void, Never> is a task that returns Void and can't throw. The two type parameters are the success type and the failure type. Task<User, Error> is a task returning User that might throw.

We'll come back to cancellation in chapter 11.

Top-level scripts

In a .swift file run as a script (not a package, not an Xcode project), you can use await at the top level:

// main.swift
let data = try await URLSession.shared.data(from: URL(string: "https://example.com")!).0
print(data)

This is convenient for tools and scripts. In an iOS app, you don't have a top-level script context — you go through Task { } or @main.


7. Tasks: The Unit of Concurrency

We've been using Task casually. Time to formalize it.

What a task is

A Task represents a piece of asynchronous work. Every async function call happens inside some task — the task is the runtime entity that holds the work's state, manages its priority, tracks cancellation, and delivers its result.

Tasks have:

When you write Task { ... }, you're creating a top-level task. When you call an async function from within a task, you don't create a new task — the call runs as part of the current task.

Top-level vs child tasks

Two kinds of tasks:

Top-level tasks are created with Task { ... } or Task.detached { ... }. They have no parent. They run independently, and the caller doesn't automatically wait for them.

Child tasks are created within structured concurrency primitives — async let or task groups. They have a parent task that owns them. The parent can't finish until all children are done. Cancellation propagates from parent to children automatically.

This guide spends a lot of time on child tasks, because they're the safer, more compositional default. Top-level tasks are the bridge from sync to async; child tasks are the unit of fine-grained concurrent work.

Task.value and Task.result

A Task is a handle to its work. You can await its result:

let task = Task {
    return try await fetchUser(id: "42")
}

let user = try await task.value

task.value awaits the task's result and rethrows any error. task.result gives you a Result<Success, Failure> instead:

let result = await task.result
switch result {
case .success(let user): print(user)
case .failure(let error): print(error)
}

task.result doesn't throw — the success/failure is in the Result.

This is occasionally useful, but mostly you write try await task.value because it composes naturally with the rest of your async code.

Task.isCancelled and Task.checkCancellation()

Inside a task, you can check whether you've been cancelled:

Task {
    for i in 0..<1000 {
        if Task.isCancelled { return }   // or: try Task.checkCancellation()
        await process(i)
    }
}

Task.isCancelled is a static property. It checks the currently running task — Swift Concurrency knows which task you're in. This is also true of Task.checkCancellation() and Task.currentPriority.

Task.yield()

A way to voluntarily give up the thread without actually waiting on anything:

for i in 0..<1_000_000 {
    process(i)
    if i.isMultiple(of: 10_000) {
        await Task.yield()
    }
}

This is for long CPU-bound loops on a cooperative thread. Without yielding, you'd hold the thread until the loop ends, blocking other tasks. With periodic Task.yield(), the runtime can pull other tasks in to run.

For most code you don't need yield. But if you have a pure-CPU async function that runs for a long time, sprinkle it in.

Task.sleep

Suspend the current task for a duration:

try await Task.sleep(for: .seconds(2))
try await Task.sleep(for: .milliseconds(500))
try await Task.sleep(until: .now + .seconds(3), clock: .continuous)

The for: form takes a Duration. The until: form takes a deadline and a clock.

Task.sleep is cancellation-aware: if the task is cancelled while sleeping, sleep throws CancellationError immediately rather than waiting out the duration. This is what you want — there's no point in waiting if the work has been cancelled.

There was an older API, Task.sleep(nanoseconds:), that you'll see in pre-iOS-16 code. Same idea, less ergonomic units. Prefer Duration when targeting iOS 16+.

Storing tasks

A Task value is a handle — you can hold onto it, pass it around, cancel it, await its result. Common patterns:

final class ViewModel {
    private var loadTask: Task<Void, Never>?

    func startLoading() {
        loadTask = Task {
            await load()
        }
    }

    func stopLoading() {
        loadTask?.cancel()
    }
}

A Task<Success, Failure> — note the generics — knows what it returns and what it can throw. Task<Void, Never> is a fire-and-forget task. Task<User, Error> is a task that produces a User or throws.

Task.detached in detail

Detached tasks don't inherit anything from their creation context:

Task.detached(priority: .background) {
    let report = try await generateReport()
    try await archive(report)
}

The use case is "I'm in some context that doesn't represent what this work should be." If you're inside a high-priority UI task and want to kick off a low-priority background job that shouldn't get the UI's priority, detached makes sense.

In practice, plain Task { } covers 90% of cases. Reach for detached when you have a reason.

Lifetime: tasks outlive their creator

A subtle but important point: if you write Task { ... } inside a method and the object is deallocated, the task keeps running:

final class Loader {
    func start() {
        Task {
            await self.expensive()
        }
    }

    func expensive() async { /* ... */ }

    deinit { print("deinit") }
}

var loader: Loader? = Loader()
loader?.start()
loader = nil
// Loader's deinit may run before the Task's body completes,
// because the Task captures `self` strongly.

The closure passed to Task { ... } captures self strongly by default. The task holds onto self until it completes. If you want the task to release its reference when the owner goes away, capture weakly:

Task { [weak self] in
    guard let self else { return }
    await self.expensive()
}

This is exactly the same dance as completion-handler callbacks: balance strong and weak captures based on lifetime requirements.


8. Structured Concurrency: The Core Principle

This is the most important idea in Swift Concurrency. Without it, you have async/await — a nice tool for sequencing work. With it, you have a discipline that makes concurrent code as predictable as sequential code.

The principle stated

A child task's lifetime is bounded by its parent's scope. The parent cannot return until all children have finished. If the parent is cancelled, all children are cancelled. If a child throws, the parent can decide whether to propagate the throw, suppress it, or cancel siblings.

That's it. Everything else is mechanism for expressing this.

Why this matters

Without structured concurrency, async tasks are easy to leak. You start a Task { } somewhere, forget to track it, and it runs until completion regardless of what the caller is doing. The caller has no way to know it's still running. Cancellation has to be wired manually. Lifetimes are an unmodeled aspect of your program.

With structured concurrency, the syntactic structure of your code maps to the structure of the concurrent work. If a function returns, you know all of its concurrent work has finished or been cancelled. There's no leak. There's no orphan task running off in the background.

The two structured primitives

Swift gives you two ways to start child tasks:

We'll cover both in detail in chapters 9 and 10.

What's not structured

Task { } and Task.detached { } are not structured concurrency primitives. They create top-level tasks that aren't bounded by any scope. Their lifetime is independent of where they were started. They're the explicit escape hatch from structured concurrency, used to bridge from sync code or to explicitly start an unbounded task.

This is why the guidance is: prefer structured concurrency. Use Task { } only when you genuinely need an unstructured top-level task — bridging from sync, or kicking off background work whose lifetime you'll manage by hand.

A small example to illustrate

Imagine fetching a user and their avatar:

// Unstructured (don't do this casually):
func loadProfile() async throws -> Profile {
    var user: User?
    var avatar: UIImage?

    Task { user = try? await fetchUser() }
    Task { avatar = try? await fetchAvatar() }

    // wait somehow??

    return Profile(user: user!, avatar: avatar!)  // good luck
}

This is unworkable. The two inner tasks aren't bounded by the function's scope. The function will return immediately after starting them, with user and avatar still nil. Crash.

// Structured (this works):
func loadProfile() async throws -> Profile {
    async let user = fetchUser()
    async let avatar = fetchAvatar()
    return try await Profile(user: user, avatar: avatar)
}

The two async let bindings are child tasks of the current function. The function won't return until both have completed (or one has thrown). They run concurrently. The await on the right of Profile(...) is what actually waits for them.

This is structured concurrency in action: clean, bounded, automatic.

Cancellation propagation

If the parent of these tasks is cancelled, both children are cancelled too — automatically, with no extra code:

let task = Task {
    return try await loadProfile()  // creates child tasks internally
}

// later:
task.cancel()  // children of loadProfile() get cancelled too

This propagation is transitive. Cancel the top-level task, and cancellation flows down through every layer of structured concurrency to the leaves. No manual cancellation token wiring.

Errors propagate naturally

If fetchUser throws inside an async let, the function loadProfile will throw the same error from the await. The other child task (fetchAvatar) is automatically cancelled to avoid wasting work.

func loadProfile() async throws -> Profile {
    async let user = fetchUser()        // suppose this throws
    async let avatar = fetchAvatar()    // gets cancelled
    return try await Profile(user: user, avatar: avatar)
    // ^ the throw from `user` exits this function;
    //   `avatar` is cancelled before the function returns
}

This is graceful failure. No half-done work. No partial state hanging around.

What to internalize

Structured concurrency is the model. async/await is the syntax. Tasks are the runtime entity. Together they give you concurrency that composes — you can take a structured async function and put it inside another, and the lifetimes nest cleanly. Outputs come back. Errors come back. Cancellation flows down.

The two specific tools for using it — async let and task groups — are the next two chapters.


9. async let — Lightweight Concurrency

async let is the simplest structured concurrency primitive. It binds a name to a value that is being computed in a child task, and you can use that name to await the result later.

The basic form

async let user = fetchUser(id: "42")
async let posts = fetchPosts(for: "42")

let combined = try await (user, posts)

When this code runs:

  1. async let user = fetchUser(id: "42") starts a child task running fetchUser. It does not await. The task starts. Control continues.
  2. async let posts = fetchPosts(for: "42") starts another child task running fetchPosts. Both tasks are now running concurrently.
  3. try await (user, posts) waits for both to complete and combines their results into a tuple.

The two fetches run in parallel. If each takes 200ms, the total wait is ~200ms, not 400.

Reading the syntax

async let x = foo() looks like a let binding. It nearly is. The differences:

You can think of async let x = foo() as roughly equivalent to:

let xTask = Task { foo() }
// later:
let x = await xTask.value

Except the structured version is automatically bounded by scope and propagates cancellation correctly.

Awaiting individual async lets

You don't have to await them all at once:

async let user = fetchUser(id: "42")
async let posts = fetchPosts(for: "42")

let userVal = try await user   // wait for user
print("got user: \(userVal.name)")

let postsVal = try await posts // wait for posts
print("got \(postsVal.count) posts")

You can also await the same async let multiple times — but only the first time actually waits; subsequent reads return the already-computed value.

Mixing concurrent and sequential

async let is most natural when the operations are independent. If one depends on another, write it sequentially:

let user = try await fetchUser(id: "42")
async let posts = fetchPosts(for: user.id)
async let avatar = fetchAvatar(url: user.avatarURL)
return try await Profile(user: user, posts: posts, avatar: avatar)

Here user is fetched first (sequentially), then we kick off posts and avatar concurrently using its values, then combine.

Cancellation: implicit and required

If you don't await an async let, what happens when the function returns?

The compiler enforces that you await (or implicitly cancel) every async let before the enclosing scope ends. Specifically: any async let that hasn't been awaited is implicitly cancelled and awaited at the end of the scope.

func experiment() async {
    async let result = expensiveWork()
    // ... we never await result
    return  // implicit: cancel `result`, await its termination
}

The function does block briefly at the end while the child task tears itself down — this is what makes the concurrency structured. There are no orphans.

In practice, this means you should always actually use the async lets you start. Starting one and not awaiting it is wasted work and slightly slows the function's exit.

Errors

If an async let's right-hand side throws, the error is delivered when you await that binding:

async let user = throwingFetch()  // throws

do {
    let u = try await user
} catch {
    // error from throwingFetch lands here
}

If you have multiple async lets and one throws when you await it, the others are still child tasks of the enclosing scope. They'll be implicitly cancelled when the function unwinds:

func loadAll() async throws -> (User, Posts) {
    async let user = fetchUser()      // suppose this throws
    async let posts = fetchPosts()
    return try await (user, posts)    // throw exits here; posts gets cancelled
}

The try await (user, posts) evaluates the tuple by awaiting both. The compiler awaits user first; if it throws, the throw propagates. Before propagating, the runtime cancels and awaits posts. Clean exit.

Limitations of async let

async let is for a fixed, known-at-compile-time set of concurrent operations. You can't put it inside a loop:

// you might want to write:
for id in ids {
    async let result = fetch(id)  // ERROR: 'async let' in loop
}

The compiler won't let you, because the lifetimes wouldn't be properly nested. For dynamic concurrency, you use task groups (chapter 10).

Putting it together

async let is the right tool when:

For everything else — dynamic counts, processing results as they arrive, or producing a single computed value from many inputs — task groups are the right tool.

A nice realistic example:

struct FullProfile {
    let user: User
    let posts: [Post]
    let followers: Int
    let following: Int
    let avatar: UIImage
}

func loadFullProfile(userID: String) async throws -> FullProfile {
    async let user = fetchUser(id: userID)
    async let posts = fetchPosts(for: userID)
    async let followers = fetchFollowerCount(for: userID)
    async let following = fetchFollowingCount(for: userID)
    async let avatar = fetchAvatar(for: userID)

    return try await FullProfile(
        user: user,
        posts: posts,
        followers: followers,
        following: following,
        avatar: avatar
    )
}

Five concurrent network calls, structured cleanly. If the user navigates away before the function returns, cancelling the parent task will cancel all five children. If any throws, the others are cancelled and the error propagates out. Hard to get more concise.


10. Task Groups

async let handles a fixed set of concurrent operations. Task groups handle a dynamic set: any number of children, started and processed at runtime.

The basic shape

let results = await withTaskGroup(of: Int.self) { group in
    for i in 0..<10 {
        group.addTask {
            return await compute(i)
        }
    }

    var total = 0
    for await value in group {
        total += value
    }
    return total
}

What's happening:

The full call returns whatever the closure returns — Int (the sum) in this case.

Reading results in completion order

Task groups don't preserve task-creation order. The first task to finish is the first value yielded by the iteration:

await withTaskGroup(of: String.self) { group in
    group.addTask {
        try? await Task.sleep(for: .seconds(3))
        return "slow"
    }
    group.addTask {
        try? await Task.sleep(for: .seconds(1))
        return "fast"
    }

    for await result in group {
        print(result)  // "fast", then "slow"
    }
}

This is exactly what you want for many problems: process whatever's ready first, don't wait on the slowest.

Specifying the result type

withTaskGroup(of: T.self) declares that every child task returns T. If you need different return types, use a sum type:

enum FetchResult {
    case user(User)
    case posts([Post])
}

await withTaskGroup(of: FetchResult.self) { group in
    group.addTask { .user(await fetchUser()) }
    group.addTask { .posts(await fetchPosts()) }

    for await result in group {
        switch result {
        case .user(let u): print("user: \(u)")
        case .posts(let p): print("posts: \(p.count)")
        }
    }
}

Throwing task groups

withTaskGroup is for non-throwing children. For throwing, use withThrowingTaskGroup:

let urls: [URL] = [...]

let datas = try await withThrowingTaskGroup(of: Data.self) { group in
    for url in urls {
        group.addTask {
            let (data, _) = try await URLSession.shared.data(from: url)
            return data
        }
    }

    var allData: [Data] = []
    for try await data in group {
        allData.append(data)
    }
    return allData
}

If any child throws, that error is delivered when you iterate (for try await). The other children are not automatically cancelled — but you can cancel them explicitly:

do {
    for try await data in group {
        allData.append(data)
    }
} catch {
    group.cancelAll()  // cancel any remaining children
    throw error
}

Or simpler, the moment you throw out of the withThrowingTaskGroup body, all remaining children are cancelled automatically. So a try-rethrow pattern is:

try await withThrowingTaskGroup(of: Data.self) { group in
    for url in urls {
        group.addTask { try await fetch(url) }
    }
    var results: [Data] = []
    for try await data in group {
        results.append(data)
    }
    return results
}

If the iteration throws, the group's body exits with an error, and the group cancels remaining children before the withThrowingTaskGroup call returns.

Discarding task groups (Swift 5.9+)

If you don't need the results — say you're firing off a bunch of side-effecting tasks — there's withDiscardingTaskGroup:

await withDiscardingTaskGroup { group in
    for event in events {
        group.addTask { await log(event) }
    }
    // implicitly waits for all to finish
}

This is more memory-efficient than a regular task group when you don't iterate results, because it doesn't accumulate them.

There's also withThrowingDiscardingTaskGroup which throws if any child throws, cancelling all others.

addTask vs addTaskUnlessCancelled

addTask always adds the child task. addTaskUnlessCancelled only adds if the group hasn't been cancelled — useful in loops where you want to stop spawning new work once cancellation has started:

for url in urls {
    let added = group.addTaskUnlessCancelled {
        try await fetch(url)
    }
    if !added { break }
}

cancelAll()

You can cancel all remaining children at any point:

for try await result in group {
    if result.isBadEnoughToStop {
        group.cancelAll()
        break
    }
    process(result)
}

The cancelAll is cooperative — children that aren't checking cancellation will run to completion, but well-written child tasks will throw CancellationError on their next checkpoint.

Limiting concurrency

Task groups don't have a built-in concurrency limit. If you addTask 10,000 times, you start 10,000 children. For some workloads (network requests against a rate-limited API) this is bad.

A common pattern is a "running count" with manual coordination:

func fetchAll(urls: [URL], maxConcurrent: Int) async throws -> [Data] {
    try await withThrowingTaskGroup(of: (Int, Data).self) { group in
        var iterator = urls.enumerated().makeIterator()
        var inflight = 0

        while inflight < maxConcurrent, let (i, url) = iterator.next() {
            group.addTask { (i, try await fetch(url)) }
            inflight += 1
        }

        var results: [(Int, Data)] = []
        while let next = try await group.next() {
            results.append(next)
            inflight -= 1
            if let (i, url) = iterator.next() {
                group.addTask { (i, try await fetch(url)) }
                inflight += 1
            }
        }

        return results.sorted { $0.0 < $1.0 }.map(\.1)
    }
}

Here we keep at most maxConcurrent tasks running. As one finishes, we start another. We carry the index along to preserve order, then sort at the end.

For more complex scheduling, swift-async-algorithms provides composable utilities, or you build a small actor-based scheduler.

When to use task groups

Use task groups when:

Use async let when you have a small fixed set of independent operations.

Reading the group as an AsyncSequence

A subtle but useful detail: TaskGroup and ThrowingTaskGroup conform to AsyncSequence. Inside the body, you can use for await value in group (or for try await). Outside the body, the group doesn't exist anymore.

You can also call group.next() directly:

while let result = try await group.next() {
    process(result)
}

This is equivalent to the for-loop and sometimes useful when you want explicit control.


11. Cancellation Throughout the System

Cancellation is one of the highest-value features of Swift Concurrency. Used well, it makes your code responsive and resource-efficient. Used poorly, you get tasks that "should have stopped" but didn't, wasting CPU and battery.

What cancellation actually is

Cancellation is cooperative. The runtime sets a flag on a task. The task's code is responsible for checking that flag and stopping. There is no preemptive "kill the task" — that would leave resources, locks, and state in unpredictable states.

The flag-setting happens in two ways:

  1. Calling task.cancel() on a top-level Task handle
  2. Cancellation propagating from a parent task (which happens automatically with structured concurrency)

The flag-checking happens in three ways:

  1. Task.isCancelled returns true after cancellation
  2. try Task.checkCancellation() throws CancellationError if cancelled
  3. Async APIs that are cancellation-aware throw or fail-fast on their own

Built-in cancellation awareness

A lot of standard-library async APIs check cancellation for you:

So in many cases you don't need to write any cancellation code yourself — the things you await propagate cancellation themselves.

Writing cancellation-aware code

For your own long loops, sprinkle in checks:

func processLargeFile(url: URL) async throws -> Result {
    let lines = url.lines  // AsyncSequence of String
    var processed = 0

    for try await line in lines {
        try Task.checkCancellation()
        processed += 1
        // ... work on line
    }

    return .success(processed: processed)
}

for try await already checks cancellation on each iteration via the AsyncSequence machinery, so the explicit checkCancellation() is belt-and-suspenders. For pure CPU loops without await, you'll want explicit checks:

func crunch(_ data: [Int]) throws -> Int {
    var sum = 0
    for i in 0..<data.count {
        if i.isMultiple(of: 1000) {
            try Task.checkCancellation()
        }
        sum += data[i] * data[i]
    }
    return sum
}

Note: crunch here isn't even async — but Task.checkCancellation() works in any context that's currently part of a task. If called outside a task, it silently does nothing.

withTaskCancellationHandler

Sometimes you need to perform cleanup or signal an external system when cancellation happens — not when you check, but immediately at the moment cancellation occurs. That's withTaskCancellationHandler:

func waitForUserInput() async throws -> String {
    return try await withTaskCancellationHandler {
        // body
        try await readUserInput()
    } onCancel: {
        // runs immediately when the task is cancelled
        cancelInputReading()
    }
}

The onCancel block runs synchronously when cancellation is signaled — possibly from a different thread. It can't be async. It's typically used to call a cancel() method on a third-party API, or signal a callback-style operation to bail out.

This is the bridge mechanism for adapting non-cancellation-aware APIs into the structured world. We'll see it again in chapter 14.

Cancellation does NOT mean "stop":

A task that's been cancelled is asked to stop, but it can continue running. It's a hint, not a kill. Code that ignores Task.isCancelled will run to completion regardless.

This means: there's no guarantee that a cancelled task will stop quickly. If you cancel a task that's busy with CPU-heavy code that doesn't check, you'll wait for it to finish. The fix is to write code that checks regularly.

Cancellation propagation

In structured concurrency, cancellation flows automatically:

let parent = Task {
    async let a = doA()
    async let b = doB()
    return try await (a, b)
}

parent.cancel()
// `a` and `b` (child tasks) are also cancelled

The runtime sets the flag on the parent and walks the tree of children, setting the flag on each. The child tasks will throw CancellationError (from their own awaits and checks) and unwind.

In task groups, cancelling the parent cancels the group, which cancels all current children. New addTask calls still add children — they won't run anything that respects cancellation, but the task is created. Use addTaskUnlessCancelled to skip adding work when the group is cancelled.

Cancellation of detached tasks

Detached tasks aren't part of any tree, so cancelling some "parent" doesn't reach them. You have to hold the task handle and call cancel() yourself:

let work = Task.detached {
    await heavyJob()
}

// later:
work.cancel()

This is part of why detached tasks are the explicit escape hatch — you're opting out of the propagation.

Don't suppress CancellationError

A common mistake:

func work() async {
    do {
        try await something()
    } catch {
        // ignore - probably fine
    }
}

If something() was cancelled, the try throws CancellationError, you swallow it, and the function continues. You've now decoupled cancellation from the work. The next await might check again and bail, but the cancellation message has been muffled.

Better:

func work() async throws {
    do {
        try await something()
    } catch is CancellationError {
        throw CancellationError()  // re-throw to propagate
    } catch {
        // handle other errors
    }
}

Or, cleaner: don't catch errors you don't know how to handle. Let cancellation propagate.

Patterns: timeouts

Swift doesn't ship a built-in timeout helper, but you can build one with task groups (we saw this in the Swift language tutorial; here's a slightly more polished version):

struct TimeoutError: Error {}

func withTimeout<T: Sendable>(
    _ duration: Duration,
    operation: @Sendable @escaping () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask {
            try await operation()
        }
        group.addTask {
            try await Task.sleep(for: duration)
            throw TimeoutError()
        }
        // first to finish wins
        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

// usage:
do {
    let data = try await withTimeout(.seconds(5)) {
        try await fetchSomething()
    }
} catch is TimeoutError {
    print("too slow")
}

The trick: race the operation against a sleep. Whichever finishes first wins. cancelAll cancels the loser.

Patterns: coalescing cancellable tasks

A common pattern in view models:

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var results: [Item] = []
    private var searchTask: Task<Void, Never>?

    func search(query: String) {
        searchTask?.cancel()
        searchTask = Task {
            // tiny debounce
            try? await Task.sleep(for: .milliseconds(300))
            if Task.isCancelled { return }

            do {
                let items = try await api.search(query: query)
                if Task.isCancelled { return }
                self.results = items
            } catch is CancellationError {
                // expected; do nothing
            } catch {
                self.results = []
            }
        }
    }
}

Each new query cancels the previous task. The 300ms sleep gives a debounce — if you cancel before that elapses, you avoid the network call entirely. The if Task.isCancelled { return } checks after the sleep and after the network call ensure we don't update UI with stale results.

This idiom is everywhere in real iOS code.

What to internalize

Cancellation is cooperative. It propagates through structured concurrency for free. It doesn't propagate through detached tasks. Code that wants to be a good citizen checks cancellation at sensible points — at the start of long loops, before expensive work, between awaited steps. Code that ignores cancellation is the source of "why is my app still running stuff after I navigated away" bugs.


12. Task Priorities

A task can have a priority, hinting to the scheduler how urgent it is. Higher-priority tasks run before lower-priority ones when threads are scarce.

The priority levels

public enum TaskPriority {
    case userInitiated  // user is waiting; run ASAP
    case high           // alias for userInitiated
    case medium         // normal work
    case `default`      // alias for medium
    case low            // background work
    case utility        // alias for low
    case background     // truly background
}

There are five distinct priority levels, with some aliases. From the runtime's perspective, the scheduler tries to run higher-priority tasks first.

Setting priority

When creating a task:

Task(priority: .userInitiated) {
    await fetchUser()
}

Task.detached(priority: .background) {
    await archiveOldLogs()
}

Priorities you don't specify inherit from the current task (for Task { }) or default to .medium (for Task.detached).

For addTask in a task group:

group.addTask(priority: .background) { ... }

Without a priority parameter, the child inherits the group's priority.

Priority inheritance

Crucially, when a task awaits another task — say, an actor call — Swift can raise the priority of the actor's work to match the awaiting task. This is priority escalation and prevents priority inversion (where a low-priority task holding an actor blocks a high-priority task waiting for it).

You usually don't need to think about this. The runtime does it. But it's why setting priorities is a useful hint and not just decoration.

Reading the current priority

let pri = Task.currentPriority

This returns the priority of the currently running task.

When priorities matter

For most apps, default priorities are fine. Where you'd reach for explicit priorities:

For UI-driven apps, the scheduler is good enough that getting priorities subtly wrong rarely matters in practice. For server-side Swift or compute-heavy code, getting priorities right can substantially affect throughput and latency.

Don't fight the scheduler

A common mistake: assuming priorities are guarantees. They're not. A .userInitiated task is preferred over a .background task, but if all threads are busy, your .userInitiated task waits. If you need real-time guarantees, you don't have them in user-space code; you need a different architecture (audio I/O on its own thread, etc.).

Treat priorities as hints to the scheduler about your intentions. Express the structure correctly and let the runtime do its job.


13. Task Local Values

Task-local values are like thread-local storage, but for tasks. They flow through structured concurrency: a child task inherits its parent's task-local values.

Declaring a task-local

You declare task-locals with the @TaskLocal property wrapper, on a static property of an enum or other type:

enum RequestContext {
    @TaskLocal static var requestID: String = "unknown"
    @TaskLocal static var user: User?
}

To read:

print(RequestContext.requestID)

To set, you can't write RequestContext.requestID = "abc". Instead, you bind it for a scope:

await RequestContext.$requestID.withValue("abc-123") {
    await processRequest()
}

Inside the closure (and any tasks spawned from inside it), RequestContext.requestID is "abc-123". Outside, it's back to the default.

Why it's useful

Common use cases:

Distributed tracing. Attach a request ID to every async operation a request kicks off, so logs across all the work for one request can be correlated.

extension Logger {
    func logWithContext(_ message: String) {
        log("[req=\(RequestContext.requestID)] \(message)")
    }
}

// Server entry point:
func handleRequest(req: Request) async {
    await RequestContext.$requestID.withValue(req.id) {
        await actuallyHandle(req)
    }
}

Now anywhere downstream, no matter how deep, RequestContext.requestID is available.

Authentication context. The current user, their permissions, propagated implicitly through every async call chain.

Test environments. Setting a "fake clock" or "in-memory database" task-local for the scope of a test, so production code doesn't need a parameter for it.

Inheritance through structured concurrency

Task-locals flow through async let, task groups, and child tasks created via Task { } from inside an enclosing task. They do not flow through Task.detached { }.

await RequestContext.$requestID.withValue("req-123") {
    Task {
        print(RequestContext.requestID)  // "req-123"
    }
    Task.detached {
        print(RequestContext.requestID)  // "unknown" (default)
    }
}

This is by design. Task.detached explicitly opts out of context inheritance, including task-locals.

Performance and limitations

Task-locals are cheap to read but slightly more expensive to bind than a regular variable. Don't use them as a substitute for normal parameter passing — only for things that genuinely need to flow through deep async stacks.

You can't have non-Sendable types in task-locals if you want concurrency safety. The compiler enforces this. For mutable shared context, use an actor; for read-only flowing context, task-locals are perfect.

A real example

enum Logger {
    @TaskLocal static var prefix: String = ""

    static func log(_ msg: String) {
        print("\(prefix)\(msg)")
    }
}

func processOrders() async throws {
    await Logger.$prefix.withValue("[order-pipeline] ") {
        try await loadAndProcess()
    }
}

func loadAndProcess() async throws {
    Logger.log("starting load")
    // outputs: "[order-pipeline] starting load"
    // ... rest of pipeline, all logs prefixed
}

The pipeline functions don't take a logger or prefix parameter. The task-local handles it.

What to internalize

Task-locals give you implicit context that flows through your async stack the way thread-locals flow through threads — but cleanly, scoped, and Sendable-checked. Use them for cross-cutting concerns (tracing, auth, logging context). Don't use them as a back door for plain mutable state.


14. Continuations: Bridging Callback APIs

Reality check: half the world's APIs use callbacks. Apple's frameworks have hundreds of delegate-based and completion-handler-based methods. The sooner you can wrap them in async/await, the sooner you can write straight-line code.

The bridge mechanism is the continuation. You use withCheckedContinuation (or its checked-throwing or unchecked variants) to capture the moment of suspension, hold a token, and resume the suspension when the callback fires.

The basic pattern

func legacyFetch(completion: @escaping (Data?, Error?) -> Void) {
    // ... old callback API
}

func modernFetch() async throws -> Data {
    return try await withCheckedThrowingContinuation { continuation in
        legacyFetch { data, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else if let data = data {
                continuation.resume(returning: data)
            } else {
                continuation.resume(throwing: MyError.unknown)
            }
        }
    }
}

Read this carefully:

  1. We call withCheckedThrowingContinuation. It suspends the current task and gives us a continuation object.
  2. We call legacyFetch, passing it a callback.
  3. When the callback fires (perhaps much later, on a different thread), we call continuation.resume(...) to deliver the result.
  4. The original await resumes with that result.

The continuation is a one-shot baton. You must call resume exactly once on it, in exactly one path. Calling it zero times deadlocks the task. Calling it twice traps.

The four resume methods

continuation.resume(returning: someValue)    // success
continuation.resume(throwing: someError)     // failure
continuation.resume(with: result)            // takes Result<T, Error>
continuation.resume()                         // for Void continuations

For non-throwing continuations (withCheckedContinuation), only resume(returning:) is available.

Checked vs unchecked

withCheckedContinuation and withCheckedThrowingContinuation use a checked continuation. The runtime tracks whether resume has been called. If it hasn't been called by the time the continuation is deallocated, the runtime traps with a clear error: "Continuation never resumed!" — saving you from a hung task.

withUnsafeContinuation and withUnsafeThrowingContinuation skip the check. Slightly faster, but if you forget to resume, you get a hang with no error. Use checked variants while developing; switch to unchecked only if profiling shows the check matters (extremely rare).

Cancellation in continuations

By default, a continuation knows nothing about cancellation. If the task is cancelled while awaiting the continuation, the continuation will still wait for the callback. To bridge cancellation, combine with withTaskCancellationHandler:

func cancellableLegacyFetch() async throws -> Data {
    let request = LegacyAPI.Request()
    return try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation { cont in
            request.start { data, error in
                if let error = error {
                    cont.resume(throwing: error)
                } else {
                    cont.resume(returning: data!)
                }
            }
        }
    } onCancel: {
        request.cancel()  // propagate cancellation to legacy API
    }
}

The onCancel block is called synchronously when cancellation hits. We signal the legacy API to cancel itself. The callback will then fire (with an error or empty data), and the continuation gets resumed normally. Clean exit.

Bridging delegate-based APIs

Delegates are harder than completion handlers because the callback might fire multiple times (one delegate, many calls). For one-shot operations, you can bridge:

class LocationFetcher: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    private var continuation: CheckedContinuation<CLLocation, Error>?

    func currentLocation() async throws -> CLLocation {
        return try await withCheckedThrowingContinuation { cont in
            self.continuation = cont
            manager.delegate = self
            manager.requestLocation()
        }
    }

    func locationManager(_ manager: CLLocationManager,
                          didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.first else { return }
        continuation?.resume(returning: location)
        continuation = nil
    }

    func locationManager(_ manager: CLLocationManager,
                          didFailWithError error: Error) {
        continuation?.resume(throwing: error)
        continuation = nil
    }
}

Notice we nil out the continuation after resuming. If the delegate fires again (which it may, since location updates are streams), the second call sees nil and skips — saving us from the double-resume trap.

For streaming delegate APIs, you don't want a single continuation; you want AsyncStream (chapter 16).

Bridging notification-based APIs

NotificationCenter has an async API now (.notifications(named:) returns an AsyncSequence), but if you need to bridge custom notification flows:

func waitForFirstNotification(_ name: Notification.Name) async -> Notification {
    await withCheckedContinuation { cont in
        var observer: NSObjectProtocol?
        observer = NotificationCenter.default.addObserver(
            forName: name, object: nil, queue: nil
        ) { note in
            if let observer = observer {
                NotificationCenter.default.removeObserver(observer)
            }
            cont.resume(returning: note)
        }
    }
}

The captured observer lets us self-remove on the first fire.

Bridging KVO

Same idea — capture the observation, resume the continuation, invalidate the observation on first fire:

func waitFor<T: NSObject, V>(
    _ object: T,
    keyPath: KeyPath<T, V>,
    matching predicate: @escaping (V) -> Bool
) async -> V {
    await withCheckedContinuation { cont in
        var observation: NSKeyValueObservation?
        observation = object.observe(keyPath, options: [.new]) { _, change in
            if let new = change.newValue, predicate(new) {
                observation?.invalidate()
                cont.resume(returning: new)
            }
        }
    }
}

Common mistakes

Calling resume twice. This is the big one. The compiler can't catch it. Rigorous bookkeeping with optional continuations (set to nil after first resume) is your defense.

Forgetting to call resume on some path. Equally bad — the calling task hangs. Walk through every code path in the closure and verify resume is called exactly once.

Capturing self strongly when self might be released. Always think about lifetimes inside continuation closures. The continuation may keep self alive longer than expected.

Using a continuation for streaming. Continuations are one-shot. Streams are streams. We'll cover those in chapter 16.

What to internalize

Continuations are the universal adapter from callback-based to async-based APIs. Use checked variants. Resume exactly once. Combine with withTaskCancellationHandler for cancellation. For streams, use AsyncStream (next chapter).


15. AsyncSequence

async/await deals with single values produced asynchronously. But many real sources produce sequences of values over time — file lines as they're read, web socket messages, sensor updates, user keystrokes. AsyncSequence is the abstraction for these.

What AsyncSequence is

AsyncSequence is to async iteration what Sequence is to sync iteration. The protocol:

protocol AsyncSequence {
    associatedtype Element
    associatedtype AsyncIterator: AsyncIteratorProtocol
        where AsyncIterator.Element == Element

    func makeAsyncIterator() -> AsyncIterator
}

protocol AsyncIteratorProtocol {
    associatedtype Element
    mutating func next() async throws -> Element?
}

The key difference: next() is async. Each iteration may suspend and wait for the next value. When the sequence is exhausted, next() returns nil.

Iterating

You iterate with for await:

for await line in someAsyncSequence {
    print(line)
}

If the iterator throws (some AsyncSequences are throwing), use for try await:

for try await line in url.lines {
    process(line)
}

The loop suspends on each iteration while the sequence produces the next value, then resumes with the value.

Built-in AsyncSequence sources

A handful of built-in async sequences in Foundation and friends:

// File lines
for try await line in url.lines {
    print(line)
}

// File bytes
for try await byte in url.resourceBytes {
    process(byte)
}

// URL bytes
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await byte in bytes {
    process(byte)
}

// Notifications (newer iOS APIs)
for await notification in NotificationCenter.default.notifications(named: .myEvent) {
    handle(notification)
}

These are all native AsyncSequence types, not bridges over callback APIs.

Standard operators

AsyncSequence has many of the same operators as Sequence:

// map, filter, reduce
let upperLines = url.lines.map { $0.uppercased() }
let errorLines = url.lines.filter { $0.contains("ERROR") }
let total = try await url.lines.reduce(0) { acc, line in acc + line.count }

// prefix, dropFirst
let firstFive = url.lines.prefix(5)
let skipFirstThree = url.lines.dropFirst(3)

// contains, allSatisfy, first(where:)
let hasError = try await url.lines.contains { $0.hasPrefix("ERROR") }
let firstError = try await url.lines.first { $0.hasPrefix("ERROR") }

// collect into array
let allLines = try await Array(url.lines)

These all return new AsyncSequences (or terminating values for the eager operators).

Note that operators like map are lazy — they produce values as iteration consumes them, just like in a regular Sequence. They don't pre-compute.

Cancellation in AsyncSequences

AsyncSequence iteration is cancellation-aware: if the consuming task is cancelled, the next next() call will typically throw CancellationError, ending the loop. This is true of all the standard sequences. For your own custom sequences, you should respect cancellation explicitly.

Implementing your own AsyncSequence

You can implement the protocol directly, but it's verbose. Here's a counter that yields integers with a delay:

struct DelayedCounter: AsyncSequence {
    typealias Element = Int
    let limit: Int

    struct AsyncIterator: AsyncIteratorProtocol {
        var current = 0
        let limit: Int

        mutating func next() async -> Int? {
            guard current < limit else { return nil }
            try? await Task.sleep(for: .milliseconds(100))
            defer { current += 1 }
            return current
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(limit: limit)
    }
}

for await n in DelayedCounter(limit: 5) {
    print(n)
}

In practice, you almost never write this. You use AsyncStream (next chapter) instead, which gives you the same capability with less ceremony.

When to use AsyncSequence

Whenever you have more than one value arriving over time:

For single async values, use async/await directly. For sequences, use AsyncSequence.


16. AsyncStream and AsyncThrowingStream

AsyncStream is the practical builder for AsyncSequences. You give it a closure that produces values via a continuation, and it gives you back an AsyncSequence you can iterate.

Construction

let stream = AsyncStream<Int> { continuation in
    continuation.yield(1)
    continuation.yield(2)
    continuation.yield(3)
    continuation.finish()
}

for await value in stream {
    print(value)  // 1, 2, 3
}

The closure runs at construction time? Not exactly — the closure is captured and runs when the stream is iterated. But the values yielded are buffered, so you can yield before iteration starts.

Realistic use: bridging a delegate

This is the killer use case. Wrapping a delegate-based, push-style API:

extension MotionManager {
    var accelerationUpdates: AsyncStream<CMAcceleration> {
        AsyncStream { continuation in
            self.startAccelerometerUpdates(to: .main) { data, _ in
                guard let data = data else { return }
                continuation.yield(data.acceleration)
            }

            continuation.onTermination = { _ in
                self.stopAccelerometerUpdates()
            }
        }
    }
}

// usage:
for await accel in motionManager.accelerationUpdates.prefix(100) {
    process(accel)
}

The flow:

  1. We create a stream. The closure runs once when iteration starts.
  2. We tell the motion manager to start delivering updates to a callback.
  3. Each callback yields a value into the continuation.
  4. We set onTermination to clean up — stop the motion manager when iteration ends (consumer breaks, gets cancelled, etc.).

onTermination is the cleanup hook. Without it, you'd leak the motion manager subscription forever.

The continuation API

continuation.yield(value)         // produce a value
continuation.yield(with: result)  // for AsyncThrowingStream: yield value or finish-with-error
continuation.finish()             // end the stream
continuation.finish(throwing: error)  // for AsyncThrowingStream
continuation.onTermination = { reason in ... }

The yield calls don't suspend. They enqueue values into the stream's buffer. The consumer pulls them off.

The onTermination callback runs when:

You can check the reason parameter (.cancelled or .finished) to differentiate.

Buffering policy

By default, AsyncStream buffers values without limit. If your producer is faster than your consumer, the buffer grows. You can change this:

AsyncStream<Int>(bufferingPolicy: .bufferingNewest(10)) { continuation in
    // keeps only the most recent 10 yielded values
}

AsyncStream<Int>(bufferingPolicy: .bufferingOldest(10)) { continuation in
    // keeps the first 10; drops new ones once full
}

AsyncStream<Int>(bufferingPolicy: .unbounded) { continuation in
    // default; never drops
}

For event streams where you only care about the latest, .bufferingNewest(1) is great — the consumer sees the most recent value when ready.

AsyncStream.makeStream()

A convenience for separating producer and consumer:

let (stream, continuation) = AsyncStream.makeStream(of: Int.self)

// Producer:
Task {
    for i in 0..<10 {
        try? await Task.sleep(for: .milliseconds(100))
        continuation.yield(i)
    }
    continuation.finish()
}

// Consumer:
for await n in stream {
    print(n)
}

This is convenient when the producer and consumer are in different scopes — you can stash the continuation in a property and call yield from anywhere.

AsyncThrowingStream

Same as AsyncStream, but the consumer's iteration can throw:

let stream = AsyncThrowingStream<Data, Error> { continuation in
    legacyAPI.start { data, error in
        if let error = error {
            continuation.finish(throwing: error)
        } else if let data = data {
            continuation.yield(data)
        }
    }
    continuation.onTermination = { _ in legacyAPI.stop() }
}

do {
    for try await chunk in stream {
        process(chunk)
    }
} catch {
    print("stream error: \(error)")
}

finish(throwing:) ends the stream with an error. The consumer's try await rethrows it.

Patterns

Single subscriber. AsyncStream is generally a single-subscriber primitive. If you iterate the same stream from two places, the values are split between them — you don't get duplicates. For multicast, you need an actor or a pub-sub mechanism.

Backpressure. AsyncStream's buffering policy is the only built-in backpressure mechanism. If you need stricter producer/consumer coordination, look at swift-async-algorithms or build a small actor-based mediator.

Combining streams. Not built in. Use swift-async-algorithms (next chapter) for merge, zip, combineLatest, etc.

A polished real-world example: tap stream from a button

extension UIControl {
    func eventStream(for event: UIControl.Event) -> AsyncStream<Void> {
        AsyncStream { continuation in
            let target = TargetForwarder { continuation.yield(()) }
            self.addTarget(target, action: #selector(TargetForwarder.fire),
                          for: event)

            continuation.onTermination = { [weak self] _ in
                self?.removeTarget(target, action: #selector(TargetForwarder.fire),
                                   for: event)
            }

            // keep target alive
            objc_setAssociatedObject(self, &targetKey, target, .OBJC_ASSOCIATION_RETAIN)
        }
    }
}

private final class TargetForwarder: NSObject {
    let block: () -> Void
    init(_ block: @escaping () -> Void) { self.block = block }
    @objc func fire() { block() }
}

// usage:
Task {
    for await _ in button.eventStream(for: .touchUpInside).prefix(5) {
        await handleTap()
    }
}

This wraps a UIKit target/action into an AsyncStream. The lifecycle is right: when the consumer stops iterating, we remove the target.

What to internalize

AsyncStream is the bridge between push-based sources (delegates, observers, callbacks, notifications) and pull-based async iteration. The contract: yield values, finish when done, cleanup in onTermination. It's the most-used tool for adapting legacy APIs into the modern world after withCheckedContinuation.


17. swift-async-algorithms

The standard library's AsyncSequence operators cover the basics. For real composition — merging, zipping, throttling, debouncing, chunking — you reach for the swift-async-algorithms package.

Adding it

In your Package.swift:

.package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.0")

And as a dependency on your target.

What's in the box

A non-exhaustive tour:

merge — interleave values from multiple sources:

import AsyncAlgorithms

let merged = merge(streamA, streamB, streamC)
for await value in merged {
    print(value)
}

The result yields values from any source as they arrive.

zip — pair up values, one from each source:

let zipped = zip(numbers, letters)
for await pair in zipped {
    print(pair)  // (1, "a"), (2, "b"), ...
}

Stops when the shorter sequence ends.

combineLatest — produce a tuple of the latest values from each source:

let combined = combineLatest(textField, dropdown)
for await (text, choice) in combined {
    updateUI(text: text, choice: choice)
}

Useful for UI: any change in any source produces a new combined value.

debounce — only emit a value if no newer one arrives within a duration:

let debounced = textField.values.debounce(for: .milliseconds(300))
for await text in debounced {
    await search(query: text)
}

Classic search-as-you-type pattern.

throttle — emit at most one value per interval:

let throttled = mouseMoves.throttle(for: .milliseconds(16))

chunked — group values into arrays:

let chunks = events.chunks(ofCount: 10)
for await batch in chunks {
    await ingest(batch)  // process 10 at a time
}

Or chunk by time:

let timedChunks = events.chunked(byTime: .milliseconds(500))

removeDuplicates — like Combine's, drop consecutive duplicates:

let deduped = values.removeDuplicates()

AsyncChannel — a multi-producer / multi-consumer point-to-point channel with backpressure:

let channel = AsyncChannel<Int>()

Task {
    for i in 0..<10 {
        await channel.send(i)
    }
    channel.finish()
}

for await value in channel {
    print(value)
}

channel.send suspends the producer until a consumer is ready — actual backpressure.

Why use the package

The standard library is conservative about what to include. Async algorithms is where the broader, opinionated tools live. If your code is doing nontrivial async stream composition, you almost certainly want the package.

A real example: search box with debouncing

@MainActor
final class SearchBox: ObservableObject {
    @Published var query = ""
    @Published var results: [Result] = []

    func observe() async {
        let queries = self.$query.values
        let debounced = queries.debounce(for: .milliseconds(300))
        let nonEmpty = debounced.filter { !$0.isEmpty }

        for await q in nonEmpty {
            do {
                self.results = try await api.search(q)
            } catch {
                self.results = []
            }
        }
    }
}

The pipeline: each character typed updates $query. After 300ms of no new keystrokes, debounce emits. We filter empty strings. We hit the search API. We update results.

This is concise enough that it almost reads like a description of the behavior.

What to internalize

For straight-line async code, you don't need this package. For pipelines — multiple sources, time-based filtering, backpressure — it's invaluable. Read the package's README for the full operator list. Many algorithms you'd reach for in Combine have direct equivalents here.


18. Actors: The Shared State Solution

We've talked about tasks, structured concurrency, and async sequences. Now we need to address mutable shared state — the deepest source of concurrency bugs.

The problem, concretely

You have a class that holds mutable state. Multiple tasks touch it.

final class Counter {
    var value = 0

    func increment() {
        value += 1
    }
}

let counter = Counter()

Task { for _ in 0..<10_000 { counter.increment() } }
Task { for _ in 0..<10_000 { counter.increment() } }
// later: print(counter.value)

What's value after both tasks finish? In sequential code, 20,000. In concurrent code with a class? Anything from somewhere around 11,000 to 19,999. The value += 1 operation is read-modify-write. Two tasks can read the same value, both increment locally, and both write back — losing one increment.

In Swift 6 with strict concurrency, this code doesn't compile (passing a non-Sendable class across task boundaries is an error). Pre-Swift-6, it compiles and silently produces wrong answers.

The pre-modern fix was a serial dispatch queue or a lock. The modern fix is an actor.

Actors

actor Counter {
    var value = 0

    func increment() {
        value += 1
    }
}

Drop-in syntax change: classactor. The semantics are different:

let counter = Counter()

Task { for _ in 0..<10_000 { await counter.increment() } }
Task { for _ in 0..<10_000 { await counter.increment() } }

Now we get exactly 20,000. The actor's runtime ensures that the read-modify-write of value += 1 happens atomically with respect to other actor methods.

What "isolated" means

Code can be in one of three isolation states relative to an actor:

  1. Inside the actor (isolated to it). Methods on the actor (without nonisolated), property accessors, etc. They can access stored properties directly.
  2. Outside the actor. Any other code. To touch the actor's state, it must await a call to an actor method. The compiler enforces this.
  3. nonisolated. Marked code on the actor that doesn't need isolation — typically because it only touches let properties or the actor's spawn-time-fixed fields.

The isolation rule means: from outside, you can't read counter.value synchronously. You'd have to call await counter.value (treating the property as an async getter), or call a method that returns it.

An important consequence: actor methods are async from outside

Even a method that doesn't await anything is async-from-outside, because reaching the actor takes a hop:

actor BankAccount {
    private var balance: Double = 0

    func deposit(_ amount: Double) {
        balance += amount  // synchronous *inside* the actor
    }
}

let account = BankAccount()
await account.deposit(100)  // async from outside

deposit doesn't have async in its declaration. But because it's an actor method, callers outside the actor must await it. The "async-ness" comes from crossing the actor boundary, not from the function's body.

Inside the actor: stays synchronous

A call from one actor method to another stays on the actor — no await:

actor BankAccount {
    private var balance: Double = 0

    func deposit(_ amount: Double) {
        balance += amount
        logTransaction(amount)  // direct call, no await
    }

    private func logTransaction(_ amount: Double) {
        print("deposited \(amount); balance now \(balance)")
    }
}

Both methods are isolated to the same actor. Calling between them doesn't cross any boundary; it's just a function call.

Reading state from outside

If you make a property non-private, you can read it from outside, but only via await:

actor BankAccount {
    var balance: Double = 0
    // ...
}

let account = BankAccount()
let b = await account.balance  // async property read

In practice, encapsulation usually wins: keep stored state private, expose methods that compute and return safe values.

Constructors

Actor initializers run synchronously and not on the actor (because the actor doesn't exist until init returns). Inside init, you can directly assign to stored properties without await:

actor Cache {
    private var storage: [String: Data]

    init(seed: [String: Data] = [:]) {
        self.storage = seed
    }
}

Initializers can be async if you need to do async work during construction:

actor LoadedCache {
    private var storage: [String: Data]

    init() async throws {
        let urls: [URL] = [...]
        var loaded: [String: Data] = [:]
        for url in urls {
            loaded[url.absoluteString] = try await fetch(url)
        }
        self.storage = loaded
    }
}

let cache = try await LoadedCache()

Actors and the Sendable model

Every actor is implicitly Sendable. You can pass an actor reference between tasks freely — the actor itself protects its state, so sharing the reference is safe. (We'll cover Sendable in detail in chapter 23.)

Actors are reference types

actor types behave like classes — they're reference types, allocated on the heap, with reference identity. Two variables pointing to the same actor refer to the same instance.

But unlike classes, you can't subclass an actor. There's no class Foo: Bar analog for actors. Inheritance and isolation interact in ways that get complicated; Swift's design is to keep actors flat.

You can have actors conform to protocols:

protocol Storage {
    func get(_ key: String) async -> Data?
    func set(_ key: String, _ value: Data) async
}

actor InMemoryStorage: Storage {
    private var dict: [String: Data] = [:]
    func get(_ key: String) async -> Data? { dict[key] }
    func set(_ key: String, _ value: Data) async { dict[key] = value }
}

The protocol's methods are async (since they'll be called across actor boundaries). The actor's implementations use the same async signature, even though internally they don't actually await anything. This is fine.

What problems actors solve

In one paragraph: actors give you mutable shared state without data races, without manual locking, without the right-pattern-or-it-crashes brittleness of DispatchQueue discipline, and with compiler enforcement that you can't accidentally bypass the protection. That's a lot of value for one keyword.

What they don't solve: high-frequency contention. If a thousand tasks are hammering one actor's methods, they queue up. The actor processes one at a time. For most application code, this is fine. For tight inner loops on shared state, you might need finer-grained patterns.


19. Actor Isolation in Detail

Actor isolation is a compile-time concept. The compiler tracks where every piece of code is allowed to run, and rejects code that would cross a boundary unsafely.

Three isolation contexts

For any line of code, the compiler knows its isolation:

The compiler enforces transitions:

Property isolation

Stored properties of an actor are isolated to it. Computed properties on an actor are isolated unless marked nonisolated:

actor User {
    let id: UUID                    // immutable; can be read directly from outside
    private var name: String         // isolated; outside code needs await

    var description: String {
        "User \(id) named \(name)"  // isolated (touches name)
    }

    nonisolated var idString: String {
        id.uuidString               // nonisolated (only touches let id)
    }
}

let u = User(...)
print(u.id)              // OK: let, can be read sync
print(u.idString)        // OK: nonisolated
print(await u.description) // requires await

Note: let properties of Sendable type can be read from outside without await. They're effectively nonisolated since they're immutable.

Methods and method isolation

A method without explicit annotation on an actor is actor-isolated:

actor Counter {
    var value = 0

    func get() -> Int { value }      // actor-isolated
    nonisolated func describe() -> String {
        "a counter"                  // nonisolated; can't read value here
    }
}

Calling describe() from outside doesn't need await:

let c = Counter()
print(c.describe())          // sync; OK
print(await c.get())         // async; await required

If describe() tried to read value, the compiler would refuse: "actor-isolated property 'value' can not be referenced from a non-isolated context."

Async vs sync isolation

A method on an actor is implicitly async-from-outside, regardless of whether its body is async:

actor Foo {
    func sync() -> Int { 42 }       // body is sync, but...
    func async() async -> Int { 43 }
}

let f = Foo()
let a = await f.sync()    // outside: still need await
let b = await f.async()

Inside the actor, sync() is sync — you can call it without await. Outside, both require await because both cross the actor boundary.

Closures and isolation

Closures inherit isolation from where they're created — usually:

@MainActor
func setup() {
    let doWork = {
        // this closure is @MainActor-isolated, because it was created in a @MainActor context
    }
}

But you can override:

@MainActor
func setup() {
    let doWork = { @MainActor in
        // explicitly @MainActor
    }

    let detached = { @Sendable in
        // Sendable closure; not actor-isolated
    }
}

Calling between two actors

If you have a reference to one actor and call a method on another:

actor A {
    let other: B
    init(other: B) { self.other = other }

    func doSomething() async {
        await other.help()
    }
}

actor B {
    func help() {
        // ...
    }
}

A.doSomething is isolated to A. The call to other.help() crosses from A's isolation to B's, which requires await. The compiler enforces it.

Closures crossing actor boundaries

When you pass a closure from one isolation context to another, the compiler checks Sendable:

actor Worker {
    func process(_ item: Item, callback: @Sendable (Result) -> Void) {
        let result = compute(item)
        callback(result)
    }
}

The @Sendable constraint says: this closure can be called from a different concurrency context safely. Without that, the compiler would reject closures that capture non-Sendable values.

Checking isolation at runtime

Sometimes you need to assert in code: "I'm definitely on this actor right now":

actor MyActor {
    func mustBeMine() {
        Self.preconditionIsolated()  // traps if not actor-isolated
    }
}

Or assert that you're not:

nonisolated func mustNotBeOnMain() {
    MainActor.assertNotIsolated()
}

These are runtime checks, useful in code that bridges between concurrency systems.

What to internalize

Actor isolation is a static property of every line of your code, tracked by the compiler. Crossing actor boundaries requires await and Sendable values. Inside the actor, you have free access to its state; outside, every access goes through await. Get this right and the compiler will catch race conditions before they ship.


20. Reentrancy: The Subtle Trap

This is the one chapter you should re-read after a few weeks of writing actor code. The misunderstanding here causes bugs that are infuriating to debug.

What reentrancy means

When an actor's method awaits something, the actor is not blocked. It doesn't sit idle. The runtime says "this method is suspended; let me see what other work I have for this actor" and runs other queued calls.

This is good for performance — actors don't waste their executor on idle awaits. But it has a sharp consequence: state can change across an await.

A buggy example

actor ImageCache {
    private var cache: [URL: Data] = [:]

    func image(for url: URL) async throws -> Data {
        if let cached = cache[url] {
            return cached
        }
        let data = try await download(url)  // suspension point!
        cache[url] = data
        return data
    }

    private func download(_ url: URL) async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }
}

What's wrong? Two tasks call image(for: url) simultaneously with the same URL.

Task 1: enters image(for:), sees nothing in cache, hits try await download(url), suspends. Task 2: enters image(for:) while Task 1 is suspended, sees nothing in cache (Task 1 hasn't written it yet), hits try await download(url), suspends.

Both downloads happen. Both eventually complete. Both write to the cache. We did the network request twice.

The bug isn't a data race — the actor protects cache from concurrent mutation. The bug is that across the await, another caller saw the actor's pre-modification state.

The fix: track in-flight work

actor ImageCache {
    private var cache: [URL: Data] = [:]
    private var inFlight: [URL: Task<Data, Error>] = [:]

    func image(for url: URL) async throws -> Data {
        if let cached = cache[url] { return cached }

        if let pending = inFlight[url] {
            return try await pending.value
        }

        let task = Task { try await self.download(url) }
        inFlight[url] = task

        defer { inFlight[url] = nil }

        let data = try await task.value
        cache[url] = data
        return data
    }

    private func download(_ url: URL) async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }
}

Now: Task 1 enters, doesn't find a cache hit, doesn't find an in-flight, creates a Task to download, stores it in inFlight, and awaits it. Task 2 enters while Task 1 is suspended, sees the in-flight task, awaits its value. Both end up with the same data, only one download happens.

The general principle: anything you check or compute before an await may be stale after it. If your invariants depend on the pre-await state, you need to either re-check or hold something across the suspension that captures the obligation.

Recheck invariants after await

A general pattern:

actor SomeResource {
    private var state: State = .idle

    func operate() async {
        guard state == .idle else { return }   // check 1
        state = .working
        await someAsyncStep()                   // suspension!
        // here, state could have changed via reentrancy
        guard state == .working else { return } // check 2
        state = .idle
    }
}

Without check 2, another method could have taken over state while you were suspended.

When reentrancy is OK

Reentrancy is not always a bug. In many cases, it's exactly what you want. An actor that takes a long time to handle one request shouldn't block all other requests — the next request can start while the first is awaiting its I/O. That's the design.

The bug is when your specific logic assumes "no one else has modified my actor's state across this await." If you have that assumption, you need the recheck.

Why doesn't Swift have non-reentrant actors?

It would solve the bug above! But it would also let one slow request block the actor for an arbitrary time — and now imagine a long chain of awaiting calls, all blocking each other. You'd get the equivalent of priority inversion bugs that are notoriously hard to fix.

The tradeoff Swift's actors make: reentrancy is on by default, you bear the responsibility for invariants that span suspension points. Future Swift may add non-reentrant actors as an option; for now, you handle it with patterns like in-flight tracking.

Reentrancy and locking

Don't try to "lock" an actor by holding state. Other tasks can still execute other methods on the same actor while you're awaiting. There's no mutual exclusion across awaits.

If you genuinely need a critical section that spans an await, you typically restructure: extract the critical part into a synchronous helper, do the awaiting outside, then re-enter the actor. Or put the awaited work in a different actor and have your "main" actor only check/update synchronously.

Recognizing reentrancy bugs

Patterns that should make you check for reentrancy issues:

When you spot one, ask yourself: "if another task slipped in here while I was suspended, would that break my logic?" If yes, fix it.

What to internalize

Across an await, anything could have happened. The actor is yours during synchronous execution; it's not yours during awaits. Code defensively. Re-check invariants. Track in-flight work explicitly. When in doubt, structure to do as little awaiting as possible inside critical sections.


21. nonisolated and Escape Hatches

Actor isolation is strict by default. nonisolated is the escape hatch when you legitimately need code on an actor that doesn't require isolation.

Why you'd need it

Two main cases:

1. Conformance to synchronous protocols. If a protocol requires a synchronous method, an actor can't conform with an isolated method (which is async-from-outside). You mark the conforming method nonisolated:

actor User: CustomStringConvertible {
    let id: UUID
    private var name: String

    nonisolated var description: String {
        "User \(id)"
    }

    init(id: UUID, name: String) {
        self.id = id; self.name = name
    }
}

CustomStringConvertible.description is a sync getter. We can't make it isolated; it would need to be async. So we mark it nonisolated and only access immutable state inside (the let id).

2. Pure utility methods on the actor that don't touch isolated state. If a method doesn't need actor isolation — say it computes something from its arguments only — make it nonisolated so callers don't have to await.

actor MathProvider {
    private var cache: [Int: Int] = [:]

    nonisolated func square(_ x: Int) -> Int {
        x * x
    }

    func memoizedSquare(_ x: Int) -> Int {
        if let cached = cache[x] { return cached }
        let result = x * x
        cache[x] = result
        return result
    }
}

square doesn't touch cache. It's nonisolated. Callers can call it directly.

What you can do in nonisolated code

A nonisolated method (or property) on an actor:

actor Account {
    let id: UUID
    private var balance: Double = 0

    nonisolated func describe() async -> String {
        let bal = await balance  // need to await isolated state
        return "Account \(id) balance=\(bal)"
    }
}

If describe is nonisolated but reads balance, it must await like any external caller.

nonisolated(unsafe)

A more dangerous variant that says "let this var be nonisolated even though it's mutable; I promise I've handled synchronization":

actor LegacyBridge {
    nonisolated(unsafe) var legacyHandle: OpaquePointer?
}

Use this only when you have a genuine reason — typically wrapping a C library where the storage is truly thread-safe but Swift can't tell. It's @unchecked Sendable for individual properties.

nonisolated async

You can have nonisolated async methods that look like ordinary actor methods but aren't isolated:

actor Service {
    nonisolated func describe() async -> String {
        "a service"  // could await stuff here, just can't touch isolated state
    }
}

The use case: a method that performs some async work using its own resources or external services, without needing the actor's lock. Reduces contention on the actor.

Composing isolated and nonisolated

A common pattern is having a public synchronous nonisolated façade that delegates to async isolated methods via Task:

actor DataStore {
    private var dict: [String: String] = [:]

    func get(_ key: String) -> String? { dict[key] }
    func set(_ key: String, _ value: String) { dict[key] = value }
}

extension DataStore {
    nonisolated func setAsync(_ key: String, _ value: String) {
        Task { await self.set(key, value) }
    }
}

Now you can call store.setAsync(...) from sync code — it kicks off a task to perform the actual set. (This is fire-and-forget; you don't see when it finishes.)

What to internalize

nonisolated is the surgical tool for marking specific methods on an actor as "doesn't need isolation." Use it for protocol conformance to sync protocols, for pure utility methods, and for situations where you want to avoid the actor hop. Avoid nonisolated(unsafe) unless you really know what you're doing.


22. @MainActor and Global Actors

So far our actors have been instances — you have an actor Counter and you create a Counter(). Global actors are different: they're singleton actor-like things that any code can be isolated to, anywhere in your codebase. The most important one is @MainActor.

@MainActor — the main thread

UIKit and AppKit have a hard rule: UI code must run on the main thread. That's been true forever. Pre-async, you'd DispatchQueue.main.async { ... } to make sure. Now you express it in the type system:

@MainActor
final class HomeViewController: UIViewController {
    @IBOutlet weak var titleLabel: UILabel!

    func updateTitle(_ text: String) {
        titleLabel.text = text  // OK; we're on the main actor
    }
}

The @MainActor annotation makes the entire class run on the main actor. Every method, every property — they're all isolated to the main actor. Calls from other isolation contexts must await.

Annotating individual methods

You don't have to mark the whole class — you can be granular:

final class HomeViewController: UIViewController {
    @MainActor
    func updateTitle(_ text: String) {
        titleLabel.text = text
    }

    func loadData() async {
        // not main-actor isolated by default
        let data = try? await fetch()
        await updateTitle(data?.title ?? "")
    }
}

Marking individual methods is more tedious but more flexible — you avoid forcing all methods onto the main actor when only some need it.

MainActor properties

You can mark individual stored properties:

final class Foo {
    @MainActor var displayedTitle: String = ""

    nonisolated func doWork() async {
        await MainActor.run {
            displayedTitle = "ready"
        }
    }
}

The property requires main-actor access. Read or write from another context, and you must await.

MainActor.run

Hop onto the main actor for a closure:

func process() async {
    let data = await heavyComputation()
    await MainActor.run {
        // we're isolated to main actor here
        self.label.text = data.summary
    }
}

MainActor.run is convenient when most of your function is non-isolated but you need a brief main-actor block. It's the modern equivalent of DispatchQueue.main.async { ... }.

MainActor.run returns whatever the closure returns (and rethrows what it throws), so you can use it inline:

let title = await MainActor.run { self.titleLabel.text ?? "" }

MainActor.assumeIsolated

Sometimes you know you're on the main thread (because the framework guarantees it for some callback type), but the compiler doesn't:

func someSyncCallback() {
    MainActor.assumeIsolated {
        // tells the compiler "we're on main actor; trust me"
        updateUI()
    }
}

This is a runtime check. If you're wrong (you're not actually on the main thread), it traps. Use sparingly — when bridging from APIs that promise main-thread delivery but lack @MainActor annotation.

Global actors in general

@MainActor is the most important global actor, but you can define your own. A global actor is declared with @globalActor:

@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
}

You provide the shared instance — there's only one. Now you can mark code with @DatabaseActor:

@DatabaseActor
func saveUser(_ user: User) throws {
    // runs on the DatabaseActor's executor; serialized with all other @DatabaseActor work
}

@DatabaseActor
final class Repository {
    var connection: Connection
    // ...
}

This is useful when you have a class of operations that should be serialized — all database writes, all logging, all analytics — without making them members of an actor instance.

Why use a custom global actor

Concrete cases:

For an iOS app, you'll mostly use @MainActor and the occasional database/logging custom actor. For server-side Swift, custom global actors come up more.

Inheritance of MainActor

If you put @MainActor on a class, its members are all main-actor isolated, and so are subclass members. If a subclass wants to override with different isolation, that's restrictive. Generally you make the whole hierarchy MainActor-isolated or none of it.

MainActor and SwiftUI

SwiftUI's View body, @State, @Binding, @StateObject, @ObservedObject, and @Environment are all main-actor-isolated by default. The View protocol's body getter is implicitly @MainActor. So your view code is on the main actor without you doing anything.

struct ContentView: View {
    @State private var count = 0

    var body: some View {
        // this whole closure is @MainActor
        Button("Tap (\(count))") {
            count += 1   // synchronous, OK on main actor
        }
    }
}

When you do async work from a SwiftUI view, you typically use the .task { } modifier or a Task { } block, and the task inherits main-actor isolation by default.

What to internalize

@MainActor is the headline global actor — it makes UI code provably correct at compile time. Custom global actors give you serialization on a per-domain basis (DB, logging, etc.). Use MainActor.run for brief hops; use @MainActor annotations for code that should always live there.


23. Sendable and the Type System

Actors solve mutable shared state. But there's a related question: when you pass a value between concurrency contexts (between tasks, into a closure for another task, across an actor boundary), is that value safe to send?

Swift's answer: the Sendable protocol. If a type is Sendable, it's safe to share between concurrency contexts. If it isn't, the compiler refuses to let you cross.

What Sendable means

Sendable is a marker protocol — it has no methods or properties. Conformance is a promise about thread safety.

public protocol Sendable {}

A type is Sendable when sharing it between concurrent tasks can't cause a data race. Concretely:

Built-in Sendable types

Most standard library types are Sendable:

So most everyday code passes Sendable values without thinking about it.

Synthesized conformance

For your own structs and enums, the compiler synthesizes Sendable automatically:

struct Point {           // implicitly Sendable
    let x: Double
    let y: Double
}

enum Status {            // implicitly Sendable
    case idle
    case running
    case done(result: String)
}

Both of these are Sendable because all their fields are Sendable. You don't need to write the conformance.

If you want to be explicit (or you want to make a public type's Sendable status clear in API):

struct Point: Sendable {
    let x: Double
    let y: Double
}

When synthesis fails

If a struct has a non-Sendable stored property, conformance isn't synthesized:

class Logger { /* not Sendable */ }

struct Service {
    let logger: Logger   // non-Sendable property
}
// Service is not Sendable

You can't pass a Service between tasks. The fix is to make Logger Sendable (turn it into an actor, or make it final-with-immutable-state), or remove it from Service.

Final classes can be Sendable

A final class with only immutable Sendable properties can conform:

final class Configuration: Sendable {
    let url: URL
    let timeout: TimeInterval
    let retries: Int

    init(url: URL, timeout: TimeInterval, retries: Int) {
        self.url = url
        self.timeout = timeout
        self.retries = retries
    }
}

Two requirements:

  1. final — no subclasses, since a subclass could add mutable state.
  2. All stored properties are let and Sendable.

The compiler verifies these. Adding a var to Configuration would break the conformance.

Generic Sendable

Generic types are Sendable when their parameters are:

struct Pair<A, B> {
    let first: A
    let second: B
}

extension Pair: Sendable where A: Sendable, B: Sendable {}

You write the conditional conformance, the compiler checks. Now Pair<Int, String> is Sendable, but Pair<Int, Logger> (with our non-Sendable Logger) isn't.

Why this matters

Without Sendable, the compiler can't tell whether a value crossing tasks is safe. Without that check, race conditions are silent. With it, the compiler refuses code like:

class MutableBag {
    var items: [String] = []
}

let bag = MutableBag()

Task {
    bag.items.append("a")    // Swift 6: ERROR
}
Task {
    bag.items.append("b")
}

In Swift 6, "non-Sendable type 'MutableBag' cannot be used in concurrently-executing code" — fail to compile, before the bug ships.

The big lesson

If you want code to be data-race-free at compile time, design your types around Sendable. Use value types (struct, enum) where possible. Use final class with immutable properties when you need reference semantics with sharing. Use actors when you need mutable shared state. Avoid mutable classes that get passed between tasks.

This is a substantial mindset shift from pre-Swift-6 code, where mutable classes are the default. Swift 6 isn't asking you to use only structs — it's asking you to be deliberate about where mutable shared state lives.


24. @Sendable Closures

Closures are values too. They can be passed between tasks. They can capture local variables. So they need their own Sendable story.

What @Sendable on a closure means

A closure marked @Sendable is one that can safely be called from any concurrency context. The compiler checks the closure's captures: each captured value must be Sendable.

func runConcurrently(_ work: @Sendable @escaping () -> Void) {
    Task { work() }
}

let n = 42
runConcurrently {
    print(n)            // OK: captures n by value, Int is Sendable
}

var counter = 0
runConcurrently {
    counter += 1        // ERROR: captures `counter` reference, not Sendable
}

The first closure captures n (an Int, value type) by value. Sendable. The second tries to capture and mutate a local var — that's a reference to mutable state, not Sendable.

Task { } requires @Sendable

The closure passed to Task { ... } must be @Sendable:

Task {
    // body must be Sendable
}

This means the captures must be Sendable. If you're inside an actor method, capturing self is a special case — we'll get to that.

Capture semantics

Closures capture by reference for var and by value for let (with optimizations). Inside a @Sendable closure, you can:

let immutableValue = 42        // Sendable let
var localVar = 5               // local var, Sendable type
let bag = MutableBag()          // non-Sendable

Task {
    print(immutableValue)       // OK
    localVar += 1               // ERROR in Swift 6: capturing var
    bag.items.append("x")       // ERROR: bag is not Sendable
}

To capture a value-type var by value (snapshot), use a capture list:

var localVar = 5
Task { [localVar] in
    print(localVar)    // OK: captured by value
}

@MainActor closures

If a closure is annotated @MainActor, it's not just @Sendable — it's specifically isolated to the main actor:

let work: @MainActor () -> Void = {
    updateUI()  // can be a non-Sendable, main-actor-isolated function
}

@Sendable is contagious

If you have an async function that takes a closure:

func process(_ items: [Int], using transform: @Sendable (Int) -> Int) async {
    // ...
}

Callers must pass a @Sendable closure. The compiler enforces this through the usual rules.

Capturing self in actor methods

If an actor method passes a closure to a Task, capturing self is fine — the actor itself is Sendable. But you generally hop off the actor before doing the work in the closure:

actor Worker {
    func process() {
        Task {
            // captures self (the actor reference)
            await self.actuallyDoIt()
        }
    }

    func actuallyDoIt() async {
        // ...
    }
}

This works because self is the actor, which is Sendable. But notice the closure's body is not isolated to self — it's a non-isolated context. That's why we need await self.actuallyDoIt().

A subtle gotcha: closures in non-async contexts

In a non-Sendable context (regular class, sync method), creating a closure that becomes Sendable later involves capture checks at the point of crossing:

class ViewModel {
    var counter = 0  // not Sendable

    func startWork() {
        Task {
            // ERROR: capturing self.counter (non-Sendable)
            self.counter += 1
        }
    }
}

In Swift 6, this is rejected. The fix depends on intent: if counter should be mutated from concurrent contexts, make ViewModel an actor. If you want main-thread mutation, mark the class @MainActor. If you want copy-on-capture, capture explicitly.

Sendable closures and noescape

If a closure is @Sendable but doesn't escape, the rules are slightly more lenient — there's a sub-feature called "non-escaping Sendable" or "region-based isolation" that we'll see in a later chapter.

What to internalize

A @Sendable closure can travel safely between tasks. The compiler enforces by checking captures. Use [capture] lists when you need to snapshot a value var. Actors and Sendable types are fine to capture; non-Sendable mutable state is not. As with Sendable types, this is mostly a discipline once you internalize it: structure your code so concurrent boundaries see Sendable values.


25. Strict Concurrency and Swift 6

Swift introduced Sendable and the actor model in Swift 5.5 (2021). But making it the default — making non-Sendable values across boundaries a compile error — was deferred. Swift 5.x has strict concurrency checking as opt-in. Swift 6 makes it the default.

Three modes

The Swift compiler supports three levels of concurrency checking:

To opt in pre-Swift-6, in Package.swift:

.target(name: "MyTarget", swiftSettings: [
    .enableExperimentalFeature("StrictConcurrency")
])

Or in Xcode: Build Settings → Strict Concurrency Checking → Complete.

What changes in strict mode

Common diagnoses you'll hit:

Non-Sendable types crossing actor or task boundaries.

class Image { /* not Sendable */ }

@MainActor
func showImage(_ image: Image) {}

Task {
    let img = await loadImage()    // returns non-Sendable Image
    await showImage(img)            // ERROR: passing non-Sendable across actor
}

Fix: make Image Sendable (final + immutable, or actor), or convert to a value type, or do the work inside the actor.

Closures capturing mutable variables.

var count = 0
Task {
    count += 1   // ERROR
}

Fix: capture by value ([count]), make count an actor's state, or restructure to not need shared mutation.

Globals that are mutable.

var globalCache: [String: Data] = [:]   // ERROR in strict mode

Globals with var and a non-Sendable type fail. Fix:

@MainActor var globalCache: [String: Data] = [:]   // isolated to main actor
// or
let globalCache = LockedCache()  // own synchronization

actor GlobalCache {
    static let shared = GlobalCache()
    private var dict: [String: Data] = [:]
    func get(_ key: String) -> Data? { dict[key] }
    func set(_ key: String, _ value: Data) { dict[key] = value }
}

Singleton class instances that are mutable. Same as globals — needs isolation or an actor.

Migration strategy

For an existing codebase, turn on warnings first. You'll see hundreds of them in any non-trivial app. Work through them in clusters — by file or feature.

Common fixes:

  1. Mark UI-related classes @MainActor.
  2. Convert mutable classes that hold shared state into actors.
  3. Make truly immutable classes final and add : Sendable.
  4. For tricky cases, use @unchecked Sendable with locking — but document it.
  5. For things that genuinely don't escape, use nonisolated(unsafe) or move to non-shared scope.

The fixes often surface design problems. A class that's "mostly immutable but has a mutable cache" probably wants an actor or @MainActor. A "global config" with a mutable property wants either to be immutable (config loaded once at startup, never mutated) or to be an actor.

Swift 6 mode

In Swift 6 mode (set in Package.swift via swiftLanguageVersions: [.v6] or in Xcode), the warnings become errors. Code with concurrency issues fails to compile. Many of Apple's own frameworks took years to be ready for this.

What this buys you

Hard-won correctness. Once your code compiles cleanly under strict concurrency, classes of bugs that used to ship to production are gone:

These bugs used to manifest as occasional crashes, weird state, or wrong values. They'd ship in shipped apps and reproduce only on certain devices, only sometimes. Strict concurrency catches them before they reach the simulator.

What it costs you

Migration time. New mental models. Frustration with compile errors that say "you have a data race here" when you were absolutely sure you didn't.

But the answer to "how long does this take" is "less than chasing those bugs in production for years would." Get the type-safety up front; reap the benefit forever.

@unchecked Sendable

When you genuinely need a class to be Sendable but the compiler can't verify it (you're using a lock, or wrapping a thread-safe C library), you write @unchecked Sendable:

final class LockedCache: @unchecked Sendable {
    private let lock = NSLock()
    private var storage: [String: Data] = [:]

    func get(_ key: String) -> Data? {
        lock.lock(); defer { lock.unlock() }
        return storage[key]
    }

    func set(_ key: String, _ value: Data) {
        lock.lock(); defer { lock.unlock() }
        storage[key] = value
    }
}

You're telling the compiler "trust me, this is thread-safe." Usually you should reach for an actor instead — but not always. Locks have lower overhead than actor hops; for very hot paths, locked classes are sometimes the right call. Just be very careful, and document the synchronization invariant.

sending parameters (Swift 5.9+)

A newer feature: a parameter marked sending transfers ownership into the function. The compiler tracks that the caller no longer uses it, so a non-Sendable value can be safely sent:

func process(_ value: sending NonSendableThing) async {
    // we're allowed to take this across actor boundaries
    // because the caller is forbidden to keep using it
}

let thing = NonSendableThing()
await process(thing)
// thing is no longer usable here
print(thing)  // ERROR: 'thing' was transferred

This is more advanced — you'll see it in library code. For most app code, designing around Sendable types covers your needs.

Region-based isolation (Swift 6)

A related new feature: the compiler analyzes the region a non-Sendable value lives in (which tasks/actors can see it) and lets you transfer it between regions if no one else can access it.

You don't typically write any new syntax for this. You benefit when the compiler accepts code that was previously a hard error — typically code where you create a non-Sendable value, pass it to one task, and don't keep using it locally.

What to internalize

Strict concurrency is the future, and it's already today. Plan for it. Learn to think in Sendable types and actors. The bugs you're forced to confront at compile time were already in your code; the compiler just made them visible. Address them once and your code is dramatically more reliable.


26. Migrating from GCD and Completion Handlers

A lot of real iOS code is GCD and completion handlers. You won't migrate it all at once, but you'll migrate it piece by piece. Here are the common patterns and their async/await replacements.

Pattern 1: completion handler → async function

Before:

func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
    URLSession.shared.dataTask(with: makeURL(id)) { data, _, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        guard let data = data else {
            completion(.failure(MyError.noData))
            return
        }
        do {
            let user = try JSONDecoder().decode(User.self, from: data)
            completion(.success(user))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}

After:

func fetchUser(id: String) async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: makeURL(id))
    return try JSONDecoder().decode(User.self, from: data)
}

Same logic, fraction of the code. You replace:

Pattern 2: keep both APIs during migration

You can maintain a callback API as a thin wrapper around an async one:

func fetchUser(id: String) async throws -> User { /* ... */ }

func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
    Task {
        do {
            let user = try await fetchUser(id: id)
            completion(.success(user))
        } catch {
            completion(.failure(error))
        }
    }
}

Now old callers keep working while new code uses async. Eventually you can deprecate and remove the callback wrapper.

Pattern 3: GCD background work → Task

Before:

DispatchQueue.global(qos: .userInitiated).async {
    let result = expensiveComputation()
    DispatchQueue.main.async {
        self.label.text = result
    }
}

After:

Task {
    let result = await expensiveComputation()
    await MainActor.run {
        self.label.text = result
    }
}

Or, if self is @MainActor and the computation is on a non-isolated function:

Task {
    let result = await expensiveComputation()
    self.label.text = result   // already on main actor
}

Pattern 4: DispatchQueue.async for serial access → actor

The old way to make a class thread-safe:

final class Counter {
    private let queue = DispatchQueue(label: "counter")
    private var _value = 0

    func increment(completion: @escaping (Int) -> Void) {
        queue.async {
            self._value += 1
            let v = self._value
            DispatchQueue.main.async {
                completion(v)
            }
        }
    }

    func value(completion: @escaping (Int) -> Void) {
        queue.async {
            let v = self._value
            DispatchQueue.main.async { completion(v) }
        }
    }
}

The new way:

actor Counter {
    private var value = 0

    func increment() -> Int {
        value += 1
        return value
    }

    func currentValue() -> Int {
        value
    }
}

Callers go from counter.increment { v in print(v) } to let v = await counter.increment(). Vastly simpler.

Pattern 5: DispatchSemaphore → continuation

Before:

func waitForReady() {
    let sema = DispatchSemaphore(value: 0)
    legacyAPI.onReady { sema.signal() }
    sema.wait()    // synchronous wait — bad in async context
}

After:

func waitForReady() async {
    await withCheckedContinuation { cont in
        legacyAPI.onReady { cont.resume() }
    }
}

The semaphore-blocking pattern was always sketchy (it ties up a thread). The continuation approach is suspension-based and doesn't block.

Pattern 6: DispatchGroup → task group or async let

Before:

let group = DispatchGroup()
var results: [Result] = []
let lock = NSLock()

for url in urls {
    group.enter()
    fetch(url) { data in
        lock.lock()
        results.append(.success(data))
        lock.unlock()
        group.leave()
    }
}

group.notify(queue: .main) {
    handleAll(results)
}

After:

let results = await withTaskGroup(of: Result.self) { group in
    for url in urls {
        group.addTask {
            do {
                let data = try await fetch(url)
                return .success(data)
            } catch {
                return .failure(error)
            }
        }
    }
    return await group.reduce(into: [Result]()) { $0.append($1) }
}

await MainActor.run { handleAll(results) }

Actually shorter and obviously correct.

Pattern 7: GCD timer → AsyncStream

Before:

let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now(), repeating: 1.0)
timer.setEventHandler {
    print("tick")
}
timer.resume()
// later: timer.cancel()

After:

let timer = AsyncStream<Date> { continuation in
    let task = Task {
        while !Task.isCancelled {
            continuation.yield(Date())
            try? await Task.sleep(for: .seconds(1))
        }
        continuation.finish()
    }
    continuation.onTermination = { _ in task.cancel() }
}

for await date in timer.prefix(10) {
    print("tick \(date)")
}

Pattern 8: OperationQueue with dependencies → structured tasks

Before:

let queue = OperationQueue()
let download = AsyncOperation { /* ... */ }
let process = AsyncOperation { /* ... */ }
let save = AsyncOperation { /* ... */ }
process.addDependency(download)
save.addDependency(process)
queue.addOperations([download, process, save], waitUntilFinished: false)

After:

Task {
    let downloaded = try await download()
    let processed = try await process(downloaded)
    try await save(processed)
}

Sequential dependencies are just sequential await. Concurrent dependencies are async let or task groups.

General migration strategy

Most teams I've seen succeed with this approach:

  1. New code uses async/await. Anything new takes async signatures from the start.
  2. Bridge at boundaries. When new async code calls old callback code, wrap with continuation. When old callback code needs to call new async code, use Task { } and bridge back to a callback.
  3. Convert leaf-by-leaf. Pick the simplest, most-used callback APIs first. Convert them. Update one or two callers. See if anything breaks. Repeat.
  4. Don't convert what's working. Some old code is fine as it is. If it's not changing and it doesn't have bugs, leave it.

The goal isn't 100% migration. The goal is async/await for new development and the parts of the codebase that benefit most.


27. Migrating from Combine

Combine and async/await both handle async work, but they have different shapes. Combine is push-based with operators on streams. Async/await is pull-based with linear code. They overlap, and you can mix them — but for many use cases, async/await is now the simpler choice.

When to keep Combine

Combine is still excellent for:

Don't rip Combine out wholesale. Coexist.

Pattern 1: single-shot publisher → async function

Before:

func fetchUser(id: String) -> AnyPublisher<User, Error> {
    URLSession.shared.dataTaskPublisher(for: makeURL(id))
        .map(\.data)
        .decode(type: User.self, decoder: JSONDecoder())
        .eraseToAnyPublisher()
}

// Usage:
fetchUser(id: "42")
    .sink(receiveCompletion: { ... }, receiveValue: { user in ... })
    .store(in: &cancellables)

After:

func fetchUser(id: String) async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: makeURL(id))
    return try JSONDecoder().decode(User.self, from: data)
}

// Usage:
Task {
    do {
        let user = try await fetchUser(id: "42")
    } catch {
        // handle
    }
}

Combine for one-shot fetches is overkill. Async/await is more direct.

Pattern 2: publisher → AsyncSequence

For multi-value streams, you can bridge a publisher to an AsyncSequence using .values:

let publisher: AnyPublisher<Int, Never> = ...

for await value in publisher.values {
    print(value)
}

This works on AnyPublisher<Output, Never> (and AnyPublisher<Output, Error> via try). You get an async iteration over published values. The subscription is automatically cancelled when the iteration ends.

This is convenient for migrating chunks of code: leave the publisher, consume it from async land.

Pattern 3: @Published → published values via .values

In an ObservableObject:

final class ViewModel: ObservableObject {
    @Published var search = ""

    func observe() async {
        for await query in $search.values {
            // react to changes
            print(query)
        }
    }
}

$search is the projected publisher of @Published. Its .values is an AsyncSequence.

Pattern 4: combineLatest → AsyncAlgorithms.combineLatest

Combine's combineLatest:

Publishers.CombineLatest(textField, dropdown)
    .sink { (text, choice) in /* ... */ }
    .store(in: &cancellables)

Async-algorithms equivalent (chapter 17):

import AsyncAlgorithms

for await (text, choice) in combineLatest(textFieldStream, dropdownStream) {
    // ...
}

Many Combine operators have direct async equivalents in swift-async-algorithms.

Pattern 5: debounce/throttle

Combine:

$search
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
    .sink { ... }

Async:

import AsyncAlgorithms

for await query in $search.values.debounce(for: .milliseconds(300)) {
    // ...
}

Pattern 6: SwiftUI lifecycle

In SwiftUI, the .task { } modifier replaces a lot of Combine subscription patterns:

struct SearchView: View {
    @StateObject var vm = SearchViewModel()

    var body: some View {
        TextField("Search", text: $vm.query)
            .task {
                await vm.observeQueries()
            }
    }
}

.task { } runs an async function for the lifetime of the view. When the view disappears, the task is cancelled. You don't need to manage subscriptions.

Pattern 7: chains of operators → straight code

A Combine pipeline:

URLSession.shared.dataTaskPublisher(for: url)
    .map(\.data)
    .decode(type: Wrapper.self, decoder: JSONDecoder())
    .map { $0.users }
    .filter { !$0.isEmpty }
    .first()
    .sink(receiveCompletion: { ... }, receiveValue: { ... })

Async equivalent:

let (data, _) = try await URLSession.shared.data(from: url)
let wrapper = try JSONDecoder().decode(Wrapper.self, from: data)
guard !wrapper.users.isEmpty else {
    throw MyError.noUsers
}
let users = wrapper.users

The async version is longer but reads top to bottom. The Combine version is more concise but obscures the "what happens if wrapper.users.isEmpty" question — filter just stops the pipeline, completion never fires, the consumer waits forever. The async version is forced to handle that case explicitly. That's usually a feature.

When the async version is harder

Some Combine patterns are genuinely better in Combine:

For these, Combine is fine. Don't rebuild what's working.

What to internalize

Combine and async/await coexist. Use async/await for one-shot operations and linear pipelines. Use Combine for SwiftUI bindings and complex multi-subscriber pub-sub. Bridge between them with .values (publisher → AsyncSequence) and Task { } (async → publisher subscriber). Migration isn't all-or-nothing.


28. iOS-Specific Patterns

Concurrency in an iOS app has shape. UI lifecycle, view models, navigation, background tasks, and the App Store's sandbox all interact with async code. This chapter is patterns for the iOS context.

.task { } in SwiftUI

The bread-and-butter SwiftUI async modifier:

struct ProfileView: View {
    let userID: String
    @State private var profile: Profile?

    var body: some View {
        Group {
            if let profile {
                Text(profile.name)
            } else {
                ProgressView()
            }
        }
        .task {
            profile = try? await loadProfile(userID: userID)
        }
    }
}

The closure runs when the view appears. The task is cancelled when the view disappears. No subscription bookkeeping.

If the task should re-run when a value changes:

.task(id: userID) {
    profile = try? await loadProfile(userID: userID)
}

When userID changes, the previous task is cancelled and a new one starts. Magic, in a good way.

.refreshable { }

For pull-to-refresh:

List(items) { item in ItemRow(item) }
    .refreshable {
        await reloadItems()
    }

The closure runs when the user pulls down. The refresh indicator stays visible until the closure returns.

Task { } in UIKit

In a UIViewController:

final class ProfileViewController: UIViewController {
    private var loadTask: Task<Void, Never>?

    override func viewDidLoad() {
        super.viewDidLoad()
        loadTask = Task { [weak self] in
            guard let self else { return }
            await self.loadProfile()
        }
    }

    deinit {
        loadTask?.cancel()
    }

    @MainActor
    func loadProfile() async {
        // since UIViewController is implicitly @MainActor in Swift 6,
        // and we're already there, just await off it
        let profile = try? await self.api.fetchProfile()
        self.label.text = profile?.name
    }
}

UIViewController got an implicit @MainActor annotation in newer SDKs, so most things just work. The loadTask storage lets you cancel on deinit, preventing work from continuing after the view controller is gone.

BGAppRefreshTask and async background work

For background refresh on iOS, you register a handler with BGTaskScheduler. The handler can be async:

BGTaskScheduler.shared.register(
    forTaskWithIdentifier: "com.example.refresh",
    using: nil
) { task in
    Task {
        let success = await refreshContent()
        task.setTaskCompleted(success: success)
    }
}

You bridge from the sync BGTaskScheduler callback into a Task. The system gives you limited time; respect the deadline.

URLSession async APIs

URLSession has full async/await support:

let (data, response) = try await URLSession.shared.data(from: url)

// download to file
let (location, response) = try await URLSession.shared.download(from: url)

// upload data
let (data, response) = try await URLSession.shared.upload(for: request, from: bodyData)

// upload from file
let (data, response) = try await URLSession.shared.upload(for: request, fromFile: fileURL)

These respect cancellation. URLSession also supports bytes(for:) for streaming:

let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await byte in bytes {
    process(byte)
}

Useful for large downloads where you want to process while the download is still happening.

Image loading with caching

A common pattern, building on actors and reentrancy:

@MainActor
final class ImageCache {
    static let shared = ImageCache()
    private var cache: [URL: UIImage] = [:]
    private var inFlight: [URL: Task<UIImage?, Never>] = [:]

    func image(for url: URL) async -> UIImage? {
        if let cached = cache[url] { return cached }

        if let pending = inFlight[url] {
            return await pending.value
        }

        let task = Task<UIImage?, Never> { [weak self] in
            do {
                let (data, _) = try await URLSession.shared.data(from: url)
                return UIImage(data: data)
            } catch {
                return nil
            }
        }

        inFlight[url] = task
        defer { inFlight[url] = nil }

        let image = await task.value
        if let image { cache[url] = image }
        return image
    }
}

The @MainActor annotation means UI code can read the cache without await. The in-flight tracking prevents duplicate downloads.

Avoiding "Task in deinit" warnings

You can't use Task { ... } capturing self in deinit cleanly — self is being deinitialized. Patterns to handle "fire something off when this object goes away":

deinit {
    let url = self.url
    Task.detached(priority: .background) {
        try? await Cache.shared.evict(url: url)
    }
}

Capture only the values you need. Task.detached doesn't try to capture self.

View model boilerplate

A typical iOS view model with structured async work:

@MainActor
final class FeedViewModel: ObservableObject {
    @Published private(set) var items: [Item] = []
    @Published private(set) var isLoading = false
    @Published var errorMessage: String?

    private let api: API
    private var loadTask: Task<Void, Never>?

    init(api: API) { self.api = api }

    func load() {
        loadTask?.cancel()
        loadTask = Task {
            isLoading = true
            defer { isLoading = false }
            do {
                items = try await api.feed()
            } catch is CancellationError {
                // expected on rapid reload
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }

    deinit { loadTask?.cancel() }
}

Clean, cancellable, error-handled, observable. This is the shape most view models will have.

Avoiding main thread checker assertions

The Main Thread Checker in Xcode warns when UIKit code runs off the main thread. With Swift Concurrency, you mostly avoid this by using @MainActor correctly.

If you're getting warnings:

Application launch and async setup

Apps often need to load configuration, check auth, sync data on launch. With async/await:

@main
struct MyApp: App {
    @StateObject private var session = Session()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(session)
                .task {
                    await session.bootstrap()
                }
        }
    }
}

@MainActor
final class Session: ObservableObject {
    @Published var user: User?

    func bootstrap() async {
        user = await loadCachedUser()
        // refresh in background
        Task {
            user = try? await api.refreshUser()
        }
    }
}

.task on a top-level view runs once when the app's UI is up. Inside, you do the bootstrap work.

What to internalize

iOS code has well-defined places to launch async work — .task, Task { } in lifecycle methods, BGTaskScheduler handlers. Use the right one for each. Cancel tasks on view dismissal/deinit. Embrace @MainActor for view models and view controllers; it eliminates an entire class of "wrong thread" bugs.


29. Testing, Debugging, and Performance

Async code presents new challenges in all three areas. Some techniques.

Testing async functions

XCTest supports async test methods directly:

final class UserTests: XCTestCase {
    func test_fetchUser_returnsExpectedUser() async throws {
        let api = MockAPI(fixedUser: User(id: "42", name: "alice"))
        let user = try await api.fetchUser(id: "42")
        XCTAssertEqual(user.name, "alice")
    }
}

The test method is async throws. XCTest awaits it. If it throws, the test fails.

For XCTestExpectation-style waiting (callbacks, notifications), you can still use those, but for pure async code, the simpler form is better.

Testing tasks and cancellation

Test that your code respects cancellation:

func test_cancellation_stopsWork() async throws {
    let task = Task {
        try await longRunningWork()
    }
    task.cancel()

    do {
        _ = try await task.value
        XCTFail("expected cancellation")
    } catch is CancellationError {
        // expected
    }
}

Testing actors

Actors are testable like any other type. Keep their interface async, await calls in tests:

func test_counter_incrementsCorrectly() async {
    let counter = Counter()
    await counter.increment()
    await counter.increment()
    let value = await counter.value
    XCTAssertEqual(value, 2)
}

Testing for races

Run a bunch of concurrent operations and verify the actor handled them correctly:

func test_counter_handlesConcurrency() async {
    let counter = Counter()

    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<1_000 {
            group.addTask { await counter.increment() }
        }
    }

    let value = await counter.value
    XCTAssertEqual(value, 1_000)
}

If Counter were a class without locking, this would sometimes fail. As an actor, it always passes — that's the value.

Mocking async APIs

A protocol-based mock:

protocol UserAPI: Sendable {
    func fetchUser(id: String) async throws -> User
}

actor MockUserAPI: UserAPI {
    var fixedUser: User?
    var fixedError: Error?

    init(user: User? = nil, error: Error? = nil) {
        self.fixedUser = user
        self.fixedError = error
    }

    func fetchUser(id: String) async throws -> User {
        if let error = fixedError { throw error }
        if let user = fixedUser { return user }
        throw MockError.unset
    }
}

The mock is itself an actor for thread safety, but it could equally be a struct or final class with immutable state.

Debugging: where am I running?

Useful diagnostic helpers:

print("Current thread: \(Thread.current)")
print("Is main thread: \(Thread.isMainThread)")
print("Task priority: \(Task.currentPriority)")
print("Task is cancelled: \(Task.isCancelled)")

In a debugger, you can inspect the current task's state. The "View Memory Graph" and "View Thread State" panels in Xcode show which tasks are running and what they're awaiting.

Thread Sanitizer (TSAN). Build → Diagnostics → Thread Sanitizer. Catches data races at runtime in debug builds. Slows execution down significantly but catches real bugs.

Main Thread Checker. Default-on. Warns when UIKit/AppKit calls happen off the main thread.

Instruments.

Common debugging pitfalls

A task seems to never finish. Likely causes:

A task runs but seemingly does nothing. Likely:

Things happen in unexpected order. Reentrancy! Or concurrent execution of work you thought was sequential. Audit await points and think about what other tasks could run there.

Performance: when async is slow

Async/await isn't free. Each suspension and resumption has overhead. For tight inner loops, this can matter.

A fully synchronous async function (no actual suspensions) is roughly 10-30ns of overhead vs a sync one. Negligible for almost any real work.

An actor hop is more expensive — typically 50-200ns when uncontended, much more when contended. If you have a hot path that hops to an actor 100,000 times per second, you'll notice.

Suspensions across executors (e.g., main → background) involve thread context handoff. Microseconds per transition. Don't do this in tight loops.

If profiling shows concurrency overhead:

Performance: avoid suspending unnecessarily

A function that's marked async but never actually suspends still has overhead — the compiler generates state-machine code, allocates a continuation, etc. If you have a "fast path" that doesn't need to be async, mark it sync:

func cachedValue(_ key: String) -> String? {
    return cache[key]   // synchronous fast path
}

func fetchValue(_ key: String) async throws -> String {
    if let cached = cachedValue(key) { return cached }
    return try await actuallyFetch(key)
}

The fast path stays out of async-land; only the slow path goes async.

What to internalize

Test async code with async test methods. Use actor mocks for thread safety in tests. Use Thread Sanitizer in debug builds. Profile with Instruments when you suspect concurrency overhead. Most of the time, performance is fine; when it's not, look at actor contention and unnecessary suspensions.


30. Common Patterns, Anti-patterns, and Where to Go Next

A grab-bag chapter to consolidate what we've covered, name patterns you'll reach for again and again, and call out anti-patterns to avoid. Then a map of where to keep learning.

Patterns worth knowing

Cancel-old-on-new. When the user takes an action that supersedes a previous one (typing a new search query, picking a different filter), cancel the previous task before starting the new:

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var results: [Item] = []
    private var searchTask: Task<Void, Never>?

    func search(_ query: String) {
        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(250))
            guard !Task.isCancelled else { return }
            do {
                results = try await api.search(query)
            } catch is CancellationError {
                // expected
            } catch {
                results = []
            }
        }
    }
}

The cancel-then-start cadence and the explicit Task.isCancelled check after the sleep are the two pieces. Together, they give you debounce + cancel-old-on-new in a few lines.

In-flight deduplication. When multiple callers ask for the same async result simultaneously, do the work once and share it:

actor Loader<Key: Hashable & Sendable, Value: Sendable> {
    private var cache: [Key: Value] = [:]
    private var inFlight: [Key: Task<Value, Error>] = [:]
    private let work: @Sendable (Key) async throws -> Value

    init(work: @escaping @Sendable (Key) async throws -> Value) {
        self.work = work
    }

    func value(for key: Key) async throws -> Value {
        if let cached = cache[key] { return cached }
        if let pending = inFlight[key] { return try await pending.value }

        let task = Task { try await self.work(key) }
        inFlight[key] = task
        defer { inFlight[key] = nil }

        let value = try await task.value
        cache[key] = value
        return value
    }
}

A general-purpose deduplicating cache. Pass in a function that loads a value for a key; the loader handles caching, in-flight tracking, and concurrent requests.

Retry with exponential backoff. For transient failures:

func retry<T: Sendable>(
    maxAttempts: Int = 3,
    initialDelay: Duration = .milliseconds(200),
    operation: @Sendable () async throws -> T
) async throws -> T {
    var delay = initialDelay
    for attempt in 1...maxAttempts {
        do {
            return try await operation()
        } catch is CancellationError {
            throw CancellationError()
        } catch {
            if attempt == maxAttempts { throw error }
            try await Task.sleep(for: delay)
            delay *= 2
        }
    }
    throw RetryError.exhausted
}

enum RetryError: Error { case exhausted }

// usage:
let data = try await retry(maxAttempts: 3) {
    try await api.fetchSomething()
}

Note the explicit cancellation check — we re-throw CancellationError immediately rather than retrying.

Producer/consumer with AsyncStream. For decoupling who-makes-events from who-handles-them:

let (events, continuation) = AsyncStream.makeStream(of: Event.self)

// Producer (somewhere):
button.addAction { continuation.yield(.tapped) }
notificationObserver { continuation.yield(.notification($0)) }

// Consumer (somewhere else):
Task {
    for await event in events {
        await handle(event)
    }
}

The continuation is the input. Anywhere you have a continuation, you can yield. The stream is the output. One consumer reads them in order.

Fan-out, fan-in. Process many things in parallel, gather results:

func processAll<T: Sendable, R: Sendable>(
    _ items: [T],
    _ transform: @Sendable @escaping (T) async throws -> R
) async throws -> [R] {
    try await withThrowingTaskGroup(of: (Int, R).self) { group in
        for (i, item) in items.enumerated() {
            group.addTask {
                let r = try await transform(item)
                return (i, r)
            }
        }
        var indexed: [(Int, R)] = []
        for try await pair in group {
            indexed.append(pair)
        }
        return indexed.sorted(by: { $0.0 < $1.0 }).map(\.1)
    }
}

Carries the original index so we can preserve order. For unordered results, drop the indexing.

Bounded concurrency. As we saw earlier, throttle parallelism to avoid overwhelming a downstream service. Maintain an "in-flight" count and feed new tasks as old ones complete.

Timeout wrapper. From chapter 11 — race the operation against a sleep, cancel the loser. Useful for any operation where "if it takes more than N seconds, give up" is reasonable.

Cooperative cancellation in long loops. Sprinkle try Task.checkCancellation() periodically. The cost is negligible; the responsiveness benefit is large.

Sendable boundaries. Push non-Sendable mutable state into actors. Make data-carrying types value types. Use final class : Sendable for immutable reference types. Reach for @unchecked Sendable only when you have specific synchronization in place and you've documented it.

Anti-patterns to avoid

Blocking inside an async task. DispatchSemaphore.wait, pthread_mutex_lock over an await, Thread.sleep, Date(timeIntervalSinceNow:) busy-waits. Any of these can deadlock the cooperative thread pool. If you need to wait, await. If you need to delay, try await Task.sleep.

Ignoring CancellationError. Catching errors generically and silently dropping cancellation:

// BAD
do {
    try await something()
} catch {
    // swallow everything
}

Cancellation is a normal control-flow signal. Either propagate it or check is CancellationError and handle it explicitly.

Doing CPU-heavy work on @MainActor. Anything you mark @MainActor runs on the main thread. If you do an O(n) loop over a million items there, the UI freezes. Move computation off the main actor:

@MainActor
func updateList() async {
    let items = await Task.detached(priority: .userInitiated) {
        return computeExpensiveList()
    }.value
    self.items = items
}

The detached task does the work; we hop back to update the property.

Using raw Task { } when structured concurrency would do. A common code smell:

// SMELL
func loadProfile() async -> Profile {
    var user: User?
    var avatar: UIImage?
    Task { user = await fetchUser() }
    Task { avatar = await fetchAvatar() }
    // wait somehow??
    // ...
}

You can't wait correctly; the function will return before children complete. Use async let or task groups. Save Task { } for top-level entry points, bridging from sync, or fire-and-forget.

Overusing Task.detached. Detached tasks are the explicit escape from inheritance. Most of the time you actually want priority and task-local-value inheritance. Reach for plain Task { } first.

Strong self capture in long-running tasks. In a view model:

// SMELL
Task {
    while true {
        await self.poll()
        try? await Task.sleep(for: .seconds(5))
    }
}

The task captures self strongly. The view model can't deinit until the task ends. The task never ends. Use [weak self]:

Task { [weak self] in
    while !Task.isCancelled {
        guard let self else { break }
        await self.poll()
        try? await Task.sleep(for: .seconds(5))
    }
}

Even better, store the task and cancel it in deinit.

Storing continuations in mutable state without nilling. A delegate-bridge pattern that fires more than once will resume a continuation more than once and trap. Always nil the stored continuation after resume:

private var continuation: CheckedContinuation<Result, Error>?

func didReceive(_ result: Result) {
    continuation?.resume(returning: result)
    continuation = nil
}

Creating an actor just to hold immutable data. If a type only has let properties, it doesn't need to be an actor. Make it a Sendable struct or final class : Sendable. Actors are for mutable state.

Hopping actors mid-loop unnecessarily. Each await on an actor is potentially a context switch. A loop that hops on every iteration:

// SLOW
for item in hugeList {
    await store.append(item)
}

It's much faster to batch:

// FAST
await store.appendAll(hugeList)

Where appendAll does the loop on the actor side.

Thinking of await as a function call boundary. await is a suspension point. State can change. Don't carry assumptions across it:

// SUSPECT
if cache[key] != nil {
    let stored = cache[key]
    let processed = await process(stored)  // oops: cache might not contain key anymore
    return cache[key]!  // crash if it was evicted
}

If you read a value before await, hold the local copy and use it. Don't re-read assuming the world hasn't changed.

Using Task.sleep for synchronization. "I'll sleep 100ms then assume the previous task is done." This is racy and brittle. Use proper coordination — await task.value, for await on a stream, an actor's state.

Passing closures with non-Sendable captures across boundaries. The compiler will complain in strict mode. Don't @unchecked Sendable your way around it; address the root cause.

Where to go next

This guide has covered most of what production iOS code needs. The frontier looks like this:

Apple's Swift Evolution proposals. The canonical, normative documents for the language. Worth reading the originals for the "why":

Find them at github.com/apple/swift-evolution.

WWDC sessions. Apple's deep dives, with examples and walkthroughs. Particularly:

Search the developer.apple.com videos page.

The swift-async-algorithms package. We touched it in chapter 17. The full operator list at github.com/apple/swift-async-algorithms is worth a browse — there are useful operators you didn't know existed.

Distributed actors. Swift has a distributed-actor extension to the actor model: actors that can live on different machines, with calls transparently going over a network transport. The swift-distributed-actors cluster framework is the reference implementation. Mostly relevant for server-side or distributed systems work, but the model is fascinating.

Custom executors. SE-0392 added the ability to write your own executor — the thing that decides which thread or queue runs an actor's work. For most app code this is overkill; for embedded systems, custom runtimes, or specialized scheduling needs, it's where the action is.

Server-side Swift. Frameworks like Vapor and Hummingbird are async/await native. Building a server is a great way to internalize structured concurrency, because the request-handling model maps naturally to "each request is a task, with child tasks for the work it spawns."

Books and ongoing content.

Profile and read source. When something behaves unexpectedly, read the standard library source. The Swift project is open source. The Concurrency runtime in the swift repo under stdlib/public/Concurrency shows you exactly what Task, AsyncStream, and the actors are doing.

A closing thought

Swift Concurrency is, more than anything, an attempt to make correct-by-default the new normal. The async/await syntax is what you'll use every day. Structured concurrency is the discipline that makes it scale. Actors and Sendable are the type-system tools that turn race conditions from runtime mysteries into compile errors.

You don't need to use everything. Most apps need async/await for I/O, Task { } to bridge from UI events, @MainActor on view code, and a handful of actors for shared state. That's a relatively small toolkit; it covers a lot.

The harder material — reentrancy, region-based isolation, custom global actors, the gnarlier corners of Sendable — is there when you need it. Reach for it when a real problem demands it. The basics, applied consistently, are usually enough.

Good luck, and have fun.