1.9 — Concurrency: async/await, Tasks, actors, Sendable

Opening scenario

Five years ago, networking code on iOS looked like this:

URLSession.shared.dataTask(with: url) { data, response, error in
    guard let data = data else {
        DispatchQueue.main.async { self.handle(error: error) }
        return
    }
    self.queue.async {
        let decoded = try? JSONDecoder().decode(User.self, from: data)
        DispatchQueue.main.async {
            self.user = decoded
            self.fetchAvatar(for: decoded) { avatar in
                DispatchQueue.main.async { self.avatar = avatar }
            }
        }
    }
}.resume()

Today it looks like this:

Task {
    let user   = try await api.fetchUser()
    let avatar = try await api.fetchAvatar(for: user)
    await MainActor.run { self.user = user; self.avatar = avatar }
}

That’s not just syntactic sugar. It’s a complete rebuild of the concurrency story: the compiler now reasons about which thread runs which code, and the type system enforces it. Welcome to the part of Swift that has been Apple’s #1 investment for half a decade.

The five concepts you need

ConceptWhat it is
async / awaitA function that may suspend, and the call site that acknowledges the suspension.
TaskA unit of asynchronous work — the entry point from synchronous to async code.
Structured concurrency (async let, TaskGroup)Spawning multiple child tasks whose lifetimes are bounded by the parent.
actorA reference type whose mutable state is isolated — only one task touches it at a time.
Sendable + @MainActorThe type-system rules that prevent data races at compile time.

Concept → Why → How → Code

async / await

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

async says “this function may suspend.” await at the call site says “I’m fine with that — pause my function here, resume me when it’s ready.” The thread is free to do other work in between. The cost of a await is roughly the cost of a function call — orders of magnitude cheaper than a thread.

Task — bridging sync and async

You can’t await from synchronous code (a button handler, a viewDidLoad). To start async work, you wrap it in a Task:

// In a SwiftUI button
Button("Refresh") {
    Task {
        await viewModel.reload()
    }
}

A Task is the entry point. Inside it, you can await freely. The closure runs on a background executor by default — unless it inherits an actor context (more in a moment).

Structured concurrency: async let and TaskGroup

Sequential await: ~2× as slow as it needs to be when calls are independent:

// SEQUENTIAL — 2 round trips
let user = try await fetchUser()
let posts = try await fetchPosts()       // waits for user first

Parallel with async let:

// PARALLEL — both kick off, you await both
async let user  = fetchUser()
async let posts = fetchPosts()
let (u, p) = try await (user, posts)

For dynamic numbers of tasks, use TaskGroup:

let avatars: [Avatar] = try await withThrowingTaskGroup(of: Avatar.self) { group in
    for user in users {
        group.addTask { try await fetchAvatar(for: user) }
    }
    var result: [Avatar] = []
    for try await avatar in group { result.append(avatar) }
    return result
}

The “structured” part is critical. All child tasks must complete (or be cancelled) before the parent returns. No orphan tasks running after the function exits. This is what makes async Swift safer than callback-pyramid code.

Actors — single-threaded mutable state

actor Cache {
    private var store: [URL: Data] = [:]

    func get(_ url: URL) -> Data? { store[url] }
    func set(_ url: URL, data: Data) { store[url] = data }
}

let cache = Cache()
await cache.set(url, data: bytes)         // every cross-actor call requires await
let value = await cache.get(url)

The actor type is a reference type (like a class) but with a runtime guarantee: only one task executes any of its methods at a time. Cross-actor calls become await calls (they may suspend if the actor is busy).

This is the right tool for shared mutable state — caches, in-memory stores, accumulators. You stop reaching for NSLock and DispatchQueue.sync.

@MainActor — the UI actor

UIKit and SwiftUI require all UI updates on the main thread. Swift now expresses this in the type system:

@MainActor
class FeedViewModel: ObservableObject {
    @Published var items: [Item] = []

    func reload() async {
        let fresh = try? await api.fetchFeed()      // hops to background
        self.items = fresh ?? []                    // back on main automatically
    }
}

Marking the class @MainActor says “everything in here runs on main.” Calls into the class from a Task on a different actor become await calls. You can mark individual functions or properties @MainActor too.

Sendable — race prevention at compile time

struct User: Sendable { let id: String; let name: String }       // ✅ all stored properties are value types
final class Logger: Sendable { let prefix: String }              // ✅ immutable class
class MutableCache { var store: [String: Data] = [:] }            // ❌ not Sendable — has mutable state

To pass a value across actor or task boundaries, Swift requires it to be Sendable. Value types of Sendable properties are automatically Sendable. final classes with only immutable properties can be Sendable. Mutable classes cannot be (use an actor instead).

Under Swift 6 strict concurrency, the compiler enforces all of this. It’s how data races become type errors instead of late-night production crashes.

In the wild

  • SwiftUI’s .task { … } modifier spawns a Task that’s automatically cancelled when the view disappears. That single line is structured concurrency in action.
  • URLSession.shared.data(from:) is the modern replacement for dataTask(with:) — fully async/await.
  • AsyncSequence and AsyncStream model streams of values over time (the async analogue to Sequence). Used in for await line in url.lines { … } for line-by-line file reading.
  • Apple’s own apps (Messages, Mail, Health) have been rewritten incrementally with actors replacing serial queues. WWDC 2024’s “Migrate to Swift 6” talk walks through their internal patterns.

Common misconceptions

  1. async means it runs on a background thread.” Not necessarily. async means the function may suspend. Where it runs depends on the actor context. A @MainActor async function still runs on main; it just doesn’t block.

  2. Task { … } is the same as DispatchQueue.global().async { … }.” No. A Task inherits the actor context of its enclosing scope by default (so inside a @MainActor method, the task runs on main). To go background explicitly, use Task.detached. The default behavior is safer but trips up GCD veterans.

  3. “Actors solve all my data-race problems.” They solve the intra-actor problem (one actor’s state is safe). Cross-actor data races require Sendable discipline, which the Swift 6 compiler enforces. Pre-Swift 6, you can still get races by passing mutable classes between actors.

  4. await always suspends.” It can suspend. Often it doesn’t — if the called function returns synchronously inside its body, no suspension happens. await is a marker that suspension is possible, not that it’s guaranteed.

  5. “Async/await is just sugar for callbacks.” It’s sugar plus a structured lifecycle. Cancellation propagates, errors propagate, child tasks are joined. Callbacks have none of that.

Seasoned engineer’s take

Concurrency is the single most consequential thing to get right in a mobile app. It’s also the area where senior and junior engineers most visibly diverge. Heuristics I rely on:

  • Default to @MainActor on your view models. The cost (occasional Task.detached for heavy work) is small; the benefit (no race conditions on @Published properties) is enormous.
  • Don’t use Task.detached unless you mean it. Detached tasks lose the actor context, the priority inheritance, and the cancellation parent. They’re the equivalent of “fire and forget” — useful but easy to abuse.
  • Make your models Sendable early. Adding Sendable later requires touching every type. Designing for it from day one means structs everywhere, immutable references, no shared mutable globals.
  • Cancel things. Long-running tasks should call try Task.checkCancellation() periodically and respect Task.isCancelled. SwiftUI’s .task modifier handles this for you, but explicit Task {} instances don’t.
  • Don’t mix GCD and async/await in new code. Pick a side. The mental model of “this work runs on actor X” doesn’t compose with “this work runs on dispatch queue Y.”

The dirty truth: migrating an old app to Swift 6 strict concurrency is painful. Apple knows this — the migration is being rolled out in waves with @preconcurrency escape hatches. But the destination is right. Apps that finish the migration are dramatically less crashy at the concurrency layer.

TIP: Use SwiftUI’s .task(id: someID) { … } to automatically re-run async work when an identifier changes (e.g., the route param). It cancels the previous task and starts a new one — exactly what you want for navigation.

WARNING: Never call a blocking synchronous API (file read, sleep, heavy compute) directly from an async function on @MainActor. The actor is the main thread; you’ll freeze the UI. Wrap CPU-heavy work in Task.detached or hop to a background actor.

Interview corner

Question: “Explain what an actor is in Swift and when you’d use one.”

Junior answer: “An actor is like a class but thread-safe.” → Right idea, no detail.

Mid-level answer: “An actor is a reference type whose internal state is automatically protected — only one task can execute any of the actor’s methods at a time. Cross-actor calls become async, since they may need to wait their turn. I use actors for shared mutable state that’s accessed concurrently: caches, in-memory stores, accumulators that used to be guarded by NSLock or a serial DispatchQueue.” → Strong.

Senior answer: Plus: “I’d also talk about the cost and the trade-offs. Every cross-actor call has a suspension cost — not huge but real, especially in tight loops. So I wouldn’t make an actor for a hot inner loop; I’d make it for coarse-grained shared state. I’d also distinguish actors from @MainActor-isolated classes: the latter pins state to a specific actor (main), the former creates a new isolation domain. For UI work, @MainActor is what you want; for background mutable state, a custom actor. And I’d mention that under Swift 6’s strict concurrency the compiler enforces Sendable at actor boundaries — so designing my model types as value types up front pays off massively. Finally, if I’m writing library code, I think hard about whether actors should be part of my public surface — they force every caller to be in an async context, which can be a viral constraint.” → Senior signal: cost-aware, considers API impact.

Red-flag answer: “I just wrap everything in Task.detached so it doesn’t block the UI.” → Tells the interviewer the candidate doesn’t understand actor isolation and is going to leak unstructured tasks all over the app.

Lab preview

Lab 1.D (Async fetcher) is the concurrency capstone — build a tiny image-fetching pipeline using URLSession, an actor-based cache, and TaskGroup for parallel fetches.


Next: how Swift actually manages memory under the hood — ARC, retain cycles, weak references. → Memory management