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.
- 1async and await
- 2Starting async work from SwiftUI
- 3Structured concurrency: parallel children
- 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
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:
@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:
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:
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 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:
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
awaitmarks 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 letand 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
Sendabletypes 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.