EduFlow

Section 4 · Shipping a Real App

async/await and Swift 6 Concurrency

20 min4 min readSpot the bug

Lesson at a glance

Write async functions, start work from SwiftUI with .task, run calls in parallel with async let and task groups, and read Swift 6 data-race errors about actors and Sendable.

  1. 1async and await
  2. 2Starting async work from SwiftUI
  3. 3Structured concurrency: parallel children
  4. 4Data races and the Swift 6 compiler

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Pocket Budget is about to call a server for exchange rates. A request might take 200 milliseconds or eight seconds on a train. If the main thread, which draws the UI and handles touches, sat waiting for it, the app would freeze and iOS might eventually kill it. Swift’s concurrency model lets you write code that waits without blocking, in a style that reads top to bottom, and Swift 6 adds a compiler that proves your concurrent code has no data races.

async and await

RateService.swift
import Foundation

struct RateService {
    func fetchRate(from base: String, to target: String) async throws -> Decimal {
        try await Task.sleep(for: .milliseconds(300))   // stands in for a network call
        return target == "EUR" ? 0.92 : 0.79
    }
}

async in the signature means “this function can pause”. Every call to it is marked await, which marks a suspension point: the function may pause there, the thread goes off to do other work (like scrolling a list), and the function resumes when the result is ready. async combines with throws, so the call is try await. In the next lesson the Task.sleep becomes a real request.

Starting async work from SwiftUI

Async functions can only be called from an async context. SwiftUI gives you one with the .task modifier:

Swift
@MainActor
@Observable
final class BudgetStore {
    var expenses: [Expense] = []
    var euroRate: Decimal?
    var isLoadingRate = false
    private let rates = RateService()

    func loadEuroRate() async {
        isLoadingRate = true
        defer { isLoadingRate = false }
        do {
            euroRate = try await rates.fetchRate(from: "USD", to: "EUR")
        } catch {
            euroRate = nil
        }
    }
}

struct RateBanner: View {
    let store: BudgetStore

    var body: some View {
        Group {
            if store.isLoadingRate {
                ProgressView()
            } else if let rate = store.euroRate {
                Text("1 USD = \(rate.formatted()) EUR")
            } else {
                Text("Rate unavailable")
            }
        }
        .task {
            await store.loadEuroRate()
        }
    }
}

.task starts when the view appears and is cancelled automatically when it disappears, so a user who leaves the screen doesn’t leave a request running in the background. For a button tap, wrap the call in Task { await store.loadEuroRate() }, which starts new top-level work. defer resets the loading flag however the function exits.

Structured concurrency: parallel children

Two independent requests shouldn’t wait for each other. async let starts work immediately and lets you collect it later:

Swift
func loadBothRates(using service: RateService) async throws -> (eur: Decimal, gbp: Decimal) {
    async let eur = service.fetchRate(from: "USD", to: "EUR")
    async let gbp = service.fetchRate(from: "USD", to: "GBP")
    return try await (eur, gbp)
}

Both requests run at the same time, so this takes about 300 ms instead of 600. For a number of tasks you only know at runtime, use a task group:

Swift
func loadRates(for codes: [String], using service: RateService) async throws -> [String: Decimal] {
    try await withThrowingTaskGroup(of: (String, Decimal).self) { group in
        for code in codes {
            group.addTask {
                (code, try await service.fetchRate(from: "USD", to: code))
            }
        }
        var rates: [String: Decimal] = [:]
        for try await (code, rate) in group {
            rates[code] = rate
        }
        return rates
    }
}

This is structured concurrency: child tasks live inside their parent’s scope. The function can’t return while a child is still running, if one child throws the others are cancelled, and cancelling the parent cancels every child. Notice that each child returns its result and the parent assembles the dictionary. Children never write to shared state.

Data races and the Swift 6 compiler

A data race happens when two threads access the same memory at the same time and at least one writes. The result is corrupted data or a crash that appears once in a thousand runs. Swift 6 language mode turns the checks for this into compile errors, organised around isolation: every piece of mutable state belongs to exactly one domain, and only code in that domain may touch it directly.

Three isolation domains. The main actor holds the views and BudgetStore. The RateCache actor holds the cached rates. Child tasks fetch rates concurrently. Only Sendable values, such as a Decimal rate, cross between domains, and every crossing is an await. @MainActor Views BudgetStore actor RateCache rates: [String: Decimal] child tasks fetch in parallel Only Sendable values cross the boundaries, and each crossing is an await
Isolation domains in Pocket Budget

Three tools cover almost every case in an app:

The main actor. @MainActor isolates a type to the main thread. UI state belongs there, which is why BudgetStore is marked @MainActor above: the views that read it run on the main actor, and now the compiler guarantees nothing else writes to it. New app projects in Xcode 26 and later set the Default Actor Isolation build setting to MainActor, so your app code is main-actor isolated unless you say otherwise. It’s a sensible default: most app code is UI code.

Actors. An actor is a reference type that protects its own state by running one call at a time. Code outside the actor must await to reach it:

Swift
actor RateCache {
    private var rates: [String: Decimal] = [:]

    func rate(for code: String) -> Decimal? {
        rates[code]
    }

    func save(_ rate: Decimal, for code: String) {
        rates[code] = rate
    }
}

// From anywhere: await cache.save(0.92, for: "EUR")

Sendable values. A type is Sendable when it’s safe to pass between domains. Value types made of Sendable parts (String, Decimal, your Expense struct) qualify automatically, as do actors and main-actor-isolated classes. A plain class with var properties doesn’t, and the compiler stops you from sharing one between tasks.

That’s the vocabulary: async, await, tasks, actors, Sendable. You don’t need to master every rule at once; read each compiler error as a question about which domain owns the data. Next you replace the pretend Task.sleep with a real request and decode the JSON it returns.

Key takeaways

  • await marks a point where a function may pause; the thread is free to do other work, such as keeping the UI responsive, while it waits.
  • Start async work from a view with .task, which SwiftUI cancels automatically when the view disappears.
  • async let and task groups run work in parallel as child tasks that must finish, or be cancelled, before their parent returns.
  • Swift 6 refuses to compile code where two tasks could touch the same mutable state at once; actors and Sendable types are how you satisfy it.
  • Keep UI state on the main actor with @MainActor; new Xcode projects make main-actor isolation the default for app code.

Further reading

Check your understanding

4 questions

Question 1 of 4

A view loads exchange rates in .task { await store.loadEuroRate() }. The user leaves the screen before the request finishes. What happens?

Question 2 of 4

async let eur = fetch("EUR") and async let gbp = fetch("GBP"), each taking about 300 ms, followed by try await (eur, gbp). Roughly how long does it take?

Question 3 of 4

Why can you call await cache.save(rate, for: "EUR") on an actor from many tasks at once without a data race?

Question 4 of 4

Which type is Sendable without you writing anything extra?

Practice

Spot the bug

The data race Swift 6 refuses to build

This helper totals a list of amounts using a task group. It would run fine in a quick test, yet the Swift 6 compiler refuses to build it. Select the line that creates the data race.

Select the line(s) you think are wrong, then check.