Swift Concurrency: async/await, Task, Actors and @MainActor

iOS App Development with Swift and SwiftUI: Beginner to Job-Ready Projects · Concurrency · 6 min read · Updated

Waiting without freezing

  1. The main thread draws the screen and must never be blocked for long.
  2. Slow work starts in a task, with the task modifier or Task.
  3. At an await, the task pauses and the main thread keeps the screen alive.
  4. When the answer is ready, the code continues and the screen updates with the result.

An iPhone app has one special thread, the main thread, which draws the screen and handles taps. If you do slow work on it, such as waiting for a web server, the app freezes and the user thinks it has crashed. Apps must do slow work *off to the side* and come back to the main thread when the result is ready. Swift has a modern, readable way to write this, called Swift Concurrency. You will use it for networking in lesson 11 and for saving data after that.

Before async/await

In older code, slow work took a completion handler, a closure called later. Several in a row became a pyramid of nested braces that was hard to read and easy to get wrong. async and await let you write the same steps as ordinary code that reads from top to bottom.

async and await

  • A function marked `async` may need to wait. While it waits, the thread is free to do other work.
  • You call it with `await`. The word marks a *possible pause*. Your code stops at that line, and continues when the answer is ready. Meanwhile, the screen keeps working.
  • A function that is async throws combines this with lesson 5. You call it with try await.
struct ExpenseService {
    func loadExpenses() async throws -> [Expense] {
        // pretend to wait for a server
        try await Task.sleep(for: .seconds(1))
        return [
            Expense(title: "Coffee", amount: 3.5),
            Expense(title: "Train", amount: 12)
        ]
    }
}

Task.sleep is a stand-in for a real wait. In lesson 11 it becomes a real network call. The shape of the code stays the same, so learning it now with a fake delay is useful.

Starting async work: the task modifier and Task

You cannot call an async function from ordinary code, because ordinary code cannot pause. You start a new task instead. In SwiftUI the best way for work that belongs to a screen is the .task modifier. It starts when the view appears and is cancelled automatically when the view goes away.

struct LoadingListView: View {
    @State private var expenses: [Expense] = []
    @State private var isLoading = false
    @State private var errorMessage: String?

    private let service = ExpenseService()

    var body: some View {
        List {
            if let errorMessage {
                Text(errorMessage).foregroundStyle(.red)
            }
            ForEach(expenses) { Text($0.title) }
        }
        .overlay {
            if isLoading { ProgressView() }
        }
        .task {
            isLoading = true
            do {
                expenses = try await service.loadExpenses()
            } catch {
                errorMessage = "Could not load. Please try again."
            }
            isLoading = false
        }
    }
}

This is the pattern behind most screens in real apps: show a spinner, wait for the data, then show the result or a friendly error. Notice that the code reads in order, with no nested closures. When you want a task from a button tap or other ordinary code, use Task { ... }.

Button("Refresh") {
    Task {
        isLoading = true
        expenses = (try? await service.loadExpenses()) ?? []
        isLoading = false
    }
}

Doing several things at once

If two calls do not depend on each other, start them together with async let, then await both. The total time is about the time of the slowest one, not the sum.

func loadDashboard() async throws -> (expenses: [Expense], budget: Double) {
    async let list = service.loadExpenses()
    async let budget = fetchBudget()
    return try await (list, budget)
}

For a variable number of calls, such as one per item in an array, Swift has task groups. They are an advanced topic. It is enough now to know that they exist.

Actors and the main actor

When many tasks run at once and touch the same data, two of them can change it at the same moment and corrupt it. This is a data race. Swift's answer is the actor. An actor is like a class that handles one request at a time, so its data is safe. You call into an actor with await, because you may have to wait your turn.

actor ExpenseCache {
    private var items: [UUID: Expense] = [:]

    func store(_ expense: Expense) { items[expense.id] = expense }
    func find(_ id: UUID) -> Expense? { items[id] }
}

let cache = ExpenseCache()
await cache.store(Expense(title: "Tea", amount: 2))

The main actor, written @MainActor, is a special actor that stands for the main thread. Code that changes the screen has to run there. Views and anything they own are on the main actor already. When you write your own class that drives a screen, as in lesson 13, mark it @MainActor so the compiler checks this for you.

Common mistakes

  • Doing slow work without async, which freezes the screen.
  • Changing the screen from the wrong thread. Mark view-driving classes @MainActor.
  • Starting a Task in onAppear that never stops. Prefer .task, which cancels itself.
  • Forgetting that after an await, other things may have changed. Do not assume the screen is as you left it.

Your task

  1. Type the ExpenseService and LoadingListView and run them. Watch the spinner for one second and then the list.
  2. Make loadExpenses throw an error on purpose and check that your message shows.
  3. Add a Refresh button that uses Task { ... }.
  4. Write the ExpenseCache actor and call it from a Task. Notice why await is needed.

How to check it worked

  • The list appears about a second after the screen opens, and the screen does not freeze meanwhile.
  • When you force an error, the red message shows and the spinner stops.
  • You can say what await marks and why an actor needs it.

Frequently asked questions

What does await do in Swift?

It marks a point where the code may pause until an async function gives its answer. While it waits, the thread is free to do other work, so the screen does not freeze.

What is the difference between Task and the task modifier?

The task modifier starts when a view appears and is cancelled when it disappears, so it suits work that belongs to a screen. Task { } starts work from ordinary code such as a button tap.

What is an actor in Swift?

A type like a class that handles one request at a time, so its data cannot be changed by two tasks at once. You call into it with await.