Swift concurrency is the biggest single improvement to iOS development in a decade, and it is the part of the language most likely to produce compiler errors you have to think about rather than just fix.
This lesson is the shape you will actually use it in — requests that should overlap, work tied to
a view's lifetime, cancellation you get for free — then @MainActor, actors, and the
three rules the compiler will enforce whether you have read them or not.
async/await, briefly
A function marked async can suspend. await marks the point where it
might. While suspended the thread is free to do something else, which is the whole trick: you write
straight-line code and get non-blocking behaviour.
What this replaced is worth remembering, because it is why the API looks the way it does. The
completion-handler version of a two-request flow nests, and every error path has to be handled in
every closure, and forgetting to call the completion in one branch hangs the caller silently. Under
async the same flow is four lines with one catch.
Requests that should overlap
The menu needs products, toppings and crusts. Written with three awaits in a row
they run one after another, tripling the wait for no reason — on a phone that is the difference
between a menu that opens and one that appears to hang.
async let products = repository.products()
async let toppings = repository.toppings()
async let crusts = repository.crusts()
let catalogue = try await Catalogue(products: products, toppings: toppings, crusts: crusts)
async let starts the work immediately and binds a promise. Nothing suspends until
the await on the last line, so all three are in flight together. The single
try await covers all of them — if any throws, that is the error you catch.
For a variable number of concurrent operations the equivalent is a task group, which is the same
idea with a loop. Reach for async let when you know the operations at compile time,
which is most of the time.
Tying work to a view
.task {
await auth.restoreSession()
await menu.reload()
await cart.hydrate()
}
.task runs when the view appears and cancels automatically when it
goes away. That is the modifier to reach for, not .onAppear { Task { … } } — the
latter starts work nobody cancels, so a customer who opens a screen and immediately leaves has left
a request running and a closure holding a reference to a view that is gone.
The ordering above is load-bearing rather than tidy: the session is restored first because an authenticated request needs the token, and the cart hydrates last because a saved line holds only identifiers and needs the catalogue to re-price it.
Cancellation is cooperative
Cancelling a task does not stop it. It sets a flag, and the task is expected to notice.
func load() {
loadTask?.cancel()
loadTask = Task { await performLoad() }
}
Cancelling the previous load before starting a new one is what stops a slow first response landing
after a fast retry and overwriting fresher data. It is the structured-concurrency
equivalent of an AbortController in a React effect cleanup, and it is one line instead
of six.
Noticing comes in two forms. Task.isCancelled is a check you make before doing
something expensive or writing state. And several built-ins throw CancellationError on
their own — Task.sleep is the one you will meet most.
Cancellation as a feature: the debounce
The cart writes itself to the server, and three taps on "+" should be one request rather than three:
persistTask?.cancel()
persistTask = Task { [persistDebounce] in
try? await Task.sleep(for: persistDebounce)
guard !Task.isCancelled else { return }
await self.persist()
}
Cancelling and re-scheduling is the entire debounce. Task.sleep throws on
cancellation, so a superseded write simply never reaches persist() — there is no flag
to check and no timer handle to clear. Compare that with the timer-plus-cleanup version in any
JavaScript codebase and the difference is not subtle.
Polling, without a timer
for attempt in 0 ..< maxAttempts {
do {
let order = try await repository.paymentStatus(orderID: orderID)
state = .loaded(order)
guard order.status.isSettling else { return }
guard attempt < maxAttempts - 1 else { break }
try await Task.sleep(for: pollInterval)
} catch is CancellationError {
return
// a second catch handles a real failure, keeping any order already on screen
A while loop with an await in it. Called from .task, it is
cancelled automatically when the view disappears: the sleep throws, the loop unwinds, and there is
nothing to remember to clean up.
The alternative — scheduling a setTimeout from inside a success handler and clearing
it on unmount — is what the React Native version of this screen does, and forgetting the cleanup
means the loop fires requests forever. On a phone that is battery and data, not just wasted work.
@MainActor
UI work must happen on the main thread. The old way was to remember; the new way is to declare it and let the compiler check:
@MainActor
@Observable
final class MenuStore {
Everything on this type is now main-actor isolated. Calling into it from a background context is a compile error rather than a runtime surprise, which is what prevents the classic "publishing changes from background threads is not allowed" crash.
What it does not mean is that everything runs on the main thread. An
async method on a main-actor type suspends at each await and the work it
awaits — a network request — happens elsewhere. Only the parts between suspensions are on the main
actor.
Three rules Swift 5.10 will enforce on you
All three produce errors that look like the compiler being difficult, and all three are pointing at something real.
A View's body is main-actor isolated; its helper properties are not.
This does not compile:
struct MenuView: View {
@Environment(MenuStore.self) private var menu
// error: main actor-isolated property 'state' can not be referenced
// from a non-isolated context
private var content: some View {
switch menu.state { ... }
}
}
The fix is to annotate the view type itself with @MainActor. Every screen in the demo
app carries it for this reason, and it is honest rather than a workaround — a SwiftUI view only ever
runs on the main thread anyway.
deinit is never actor-isolated. It runs on whichever thread releases
the last reference, so it cannot touch isolated state. This does not compile
either:
@MainActor
final class CartStore {
private var persistTask: Task<Void, Never>?
// error: main actor-isolated property 'persistTask' can not be
// referenced from a non-isolated context
deinit { persistTask?.cancel() }
}
Reaching for MainActor.assumeIsolated to silence it would be asserting something
untrue. The right answer is usually that the cleanup was not needed: tasks that capture
self weakly keep nothing alive, and cancellation that genuinely matters belongs in a
method the owner calls while the object is still alive.
A default argument is evaluated in a nonisolated context. Even inside a
@MainActor function, which makes this does not compile territory:
@MainActor
private func makeModel(
// error: call to main actor-isolated initializer in a
// synchronous nonisolated context
gateway: StubPaymentGateway = StubPaymentGateway(isReady: true)
) -> CheckoutViewModel { ... }
Default to nil and construct in the body. Swift 6 changes this to use the caller's
isolation; until then it is a spelling you simply have to know.
Actors
An actor protects its own state by serialising access to it. No locks to remember, no data races
to reason about, and the compiler enforces it — every access from outside is awaited.
actor TokenStore: AuthTokenProviding {
private let secureStore: SecureStore
private var cachedToken: String?
private var hasLoaded = false
The session token is the right thing to protect this way, because it is genuinely touched from several places at once: the HTTP client reads it on every authenticated request, sign-in writes it, sign-out clears it — and a customer can tap "Sign out" while a menu refresh is in flight.
func currentToken() async -> String? {
if hasLoaded { return cachedToken }
cachedToken = try? secureStore.string(for: .authToken)
hasLoaded = true
return cachedToken
}
"Load once, then reuse" is a pattern that is unsafe to write in a shared object without a lock. Inside an actor it is just code.
Reentrancy, the part that catches people
An actor guarantees that only one task is executing its code at a time. It does not
guarantee that a method runs to completion before another starts. At every await inside
an actor method, the actor can service someone else.
So state read before an await may be stale after it. The rule that follows is
simple: re-check anything you depend on after awaiting, and do not assume an
invariant holds across a suspension point. This is the actor equivalent of the check-then-act race,
and it is the one thing about actors worth being careful with.
Sendable
Sendable marks a type as safe to pass between concurrency domains. Value types of
value types get it automatically; a class has to earn it. When you know a type is thread-safe but
the compiler cannot, @unchecked Sendable is the escape hatch — and it should never
appear without a comment saying who is making the promise and why it holds. The demo app uses it
once, for a wrapper around UserDefaults, which Apple documents as thread-safe but never
annotated.
Parallel work in a view model
One more use of async let, because the shape recurs: two independent reads that a
screen needs together.
do {
// Independent requests, so they go in parallel rather than one after the other.
async let addresses = repository.addresses()
async let paymentMethods = repository.paymentMethods()
state = try await .loaded(Content(addresses: addresses, paymentMethods: paymentMethods))
} catch {
guard !ErrorPresenter.isCancellation(error) else { return }
state = .failure(error, fallback: "Could not load your profile.")
}
The catch is the part worth copying. Checking for cancellation first means a screen
that was dismissed mid-load does not flash an error on its way out — and since the state assignment
is skipped entirely, there is nothing left behind for the next appearance to render.
What to avoid
Three habits that look reasonable.
Do not use Task { } where .task would do. A bare task
in onAppear outlives the view, and the work it is doing is usually work nobody wants
any more.
Do not use DispatchQueue.main.async in new code. It is the thing
@MainActor replaced, and mixing the two means the compiler can no longer check what it
was meant to be checking.
Do not reach for Task.detached to "get off the main thread". It
drops the current context — priority, task-local values and cancellation — so a detached task is one
nothing can stop. The everyday way to move work off the main actor is to call an
async function that is not main-actor isolated, which is what the HTTP client already
is.
Next
Stores, repositories, view models and a client have all appeared without anyone drawing the picture. The next lesson does: the layering underneath, what MVVM actually covers, and the one piece of state worth being strict about.