Skip to content

Learn Swift Concurrency (Async, Await, Actors) | Modern Swift Concurrency #0

Before Swift's modern concurrency system, writing safe asynchronous code meant juggling Grand Central Dispatch queues, @escaping closures, and manual DispatchQueue.main.async calls to get back to the UI thread — this series replaces all of that with async/await, Task, and actors, tools that let the compiler enforce thread safety instead of leaving it to you.

What You'll Learn

  • How async/await, Task, async let, and actors fit together as a unified concurrency system
  • Why @MainActor is the modern replacement for DispatchQueue.main.async and how it eliminates a whole class of UI threading bugs
  • How an actor serializes access to shared mutable state and why the compiler requires await on every cross-actor call

Mental Model

Think of Swift's concurrency system as a well-run restaurant. The kitchen (background work) can have many cooks working simultaneously, but each order station (actor) has exactly one cook at a time — no two people grab the same pan at once. When a waiter (a Task) needs something from the kitchen, they place the order (await) and are free to take other tables while waiting, rather than standing frozen at the pass. The @MainActor is the front-of-house manager: all communication with the dining room (the UI) has to go through them.

The key insight in this overview is that Swift's concurrency model is structured: tasks have clear lifetimes, actors own their state exclusively, and await is an explicit signal to both the compiler and the reader that something asynchronous is happening here. This structure is what allows the compiler to catch data races at compile time rather than letting them appear as random crashes at runtime.

Detailed Explanation

Swift concurrency was introduced in Swift 5.5 (Xcode 13, iOS 15) to solve a fundamental problem: asynchronous code written with callbacks and GCD is correct only if the programmer remembers to dispatch to the right queue at every callsite. Forget one DispatchQueue.main.async and you update UIKit from a background thread. Share a mutable object between two GCD queues and you have a data race that may only crash in production under load.

The three pillars of the system each solve a distinct problem. async/await solves readability and control flow: instead of nested completion handlers, you write sequential-looking code that the Swift runtime can suspend and resume cooperatively. Every await is a suspension point — the current task yields the thread back to the scheduler, which can run other tasks until the awaited work completes. Crucially, no thread is blocked; threads are never waiting idle.

Task solves lifetime and structure. Every piece of async work must live inside a Task, which gives that work a priority, a cancellation handle, and a clear parent relationship. The .task view modifier in SwiftUI creates a Task that is automatically cancelled when the view disappears, preventing a common bug where a dismissed screen still mutates state.

Actors solve data safety. An actor is a reference type (like a class) but with a built-in serialization guarantee: only one caller can execute inside the actor at a time. When you await actor.someMethod(), you're not just waiting for the method to finish — you're waiting for the actor to be free to serve your call. The compiler enforces this: accessing an actor-isolated property from outside the actor without await is a compile error, not a runtime bug.

@MainActor is a global actor that represents the main thread. Annotating a class with @MainActor (as seen with the view model here) tells the compiler that all of its state and methods must run on the main thread, so you never need to manually dispatch UI updates. The compiler will error if you try to access @MainActor-isolated state from a non-isolated context without await.

Code Structure

00-learn-swift-concurrency-async-await-actors.swift contains three declarations that each demonstrate a different concurrency pillar. DownloadCounter is a simple actor showing how shared mutable state is protected. ConcurrencyOverviewViewModel is a @MainActor-isolated ObservableObject that uses async let to fire two concurrent fetches simultaneously. ConcurrencyOverviewView is the SwiftUI view that ties everything together using the .task modifier.

Complete Code

00-learn-swift-concurrency-async-await-actors.swift

swift
import SwiftUI

actor DownloadCounter {
    private var count = 0 // actor-isolated: only accessible from within this actor

    func increment() -> Int {
        count += 1 // safe to mutate without a lock because the actor serializes access
        return count
    }
}

@MainActor // every property and method runs on the main thread — no manual DispatchQueue.main needed
final class ConcurrencyOverviewViewModel: ObservableObject {
    @Published var status = "Ready"
    @Published var completedDownloads = 0

    private let counter = DownloadCounter() // actor instance, shared safely across tasks

    func loadDashboard() async {
        status = "Loading" // safe: we're already on MainActor

        async let profile = fetchProfile()   // starts fetchProfile immediately without waiting
        async let settings = fetchSettings() // starts fetchSettings immediately, runs concurrently with fetchProfile

        let result = await "\(profile) + \(settings)" // suspends here until BOTH concurrent calls finish
        completedDownloads = await counter.increment() // await required: crossing the actor boundary into DownloadCounter
        status = "Loaded \(result)"
    }

    private func fetchProfile() async -> String {
        try? await Task.sleep(nanoseconds: 500_000_000) // simulates a 0.5s network delay without blocking the thread
        return "Profile"
    }

    private func fetchSettings() async -> String {
        try? await Task.sleep(nanoseconds: 300_000_000) // simulates a 0.3s network delay; runs concurrently with fetchProfile
        return "Settings"
    }
}

struct ConcurrencyOverviewView: View {
    @StateObject private var viewModel = ConcurrencyOverviewViewModel()

    var body: some View {
        VStack(spacing: 16) {
            Text(viewModel.status)
                .font(.headline)

            Text("Completed downloads: \(viewModel.completedDownloads)")
                .foregroundStyle(.secondary)
        }
        .task { // SwiftUI creates a Task here and cancels it automatically when this view disappears
            await viewModel.loadDashboard()
        }
    }
}

Code Walkthrough

  1. actor DownloadCounter — This is the simplest possible actor: one piece of private mutable state and one method that mutates it. Because it is an actor, Swift guarantees that increment() is never called by two tasks at the same time. There are no locks, no queues — the compiler handles the exclusivity.

  2. @MainActor on the ViewModel — Instead of sprinkling DispatchQueue.main.async at every point where we update @Published properties, we annotate the whole class. The compiler now statically verifies that nothing writes to status or completedDownloads from a background context.

  3. async let profile and async let settings — These two lines start two independent async calls at the same moment. Without async let, you'd call fetchProfile() first, wait 500ms, then call fetchSettings() and wait 300ms: 800ms total. With async let, both run concurrently and the total wait is only 500ms (the longer of the two). The work only joins back at the await (profile, settings) line.

  4. await counter.increment() — Even though ConcurrencyOverviewViewModel is on @MainActor, counter is a separate actor. The await here is mandatory because the call crosses from one actor's isolation domain into another. The main actor suspends, the download counter runs its increment, and then the main actor resumes.

  5. .task modifier — This is the SwiftUI-native way to start async work tied to a view. If the user navigates away before loadDashboard() finishes, SwiftUI cancels the underlying Task. Using onAppear { Task { ... } } instead would leave an orphaned task running with no cancellation signal.

Common Mistakes

Mistake: Using onAppear { Task { ... } } instead of .taskonAppear with a manually created Task does not cancel when the view disappears. If the user taps back mid-load, the task keeps running and may try to update state on a deallocated view model. The .task modifier is purpose-built to handle this: it cancels the task on view disappearance automatically.

Mistake: Marking actor methods nonisolated just to remove the await at the callsitenonisolated opts a method out of the actor's isolation guarantees. If that method then touches the actor's mutable state, you've reintroduced the data race the actor was designed to prevent. Only use nonisolated for methods that genuinely don't need access to the actor's isolated state (e.g., pure computations or access to let constants).

Mistake: Calling across actor boundaries in a tight timer loop without throttling Each await actor.method() involves a context switch. In HomeView and BrowseView in lesson 9, timers fire every 10–100ms. Firing hundreds of actor hops per second creates scheduling pressure and can lead to stale UI updates piling up. Always ask: does this work need to happen on every timer tick, or can it be debounced?

Key Takeaways

  • async/await makes asynchronous code read linearly — every await is a clearly labelled suspension point where no thread is wasted waiting
  • @MainActor replaces manual DispatchQueue.main.async calls; annotate your entire ViewModel and the compiler enforces main-thread safety for all UI state
  • Actors serialize access to shared mutable state at compile time, eliminating the data races that make class-based shared state dangerous in concurrent code

Last updated: June 27, 2026

Released under the MIT License.