swift · essay · concurrency

Understanding Swift async/await: Flow, Not Syntax

Published March 9, 2026 · 4 min read · intermediate

The confusion

Swift’s async and await look deceptively simple.

You replace completion handlers, add a keyword, and suddenly everything reads top to bottom. Yet many developers still feel unsure about what actually happens at runtime.

Is the function paused? Is a thread blocked? Where does execution continue?

The syntax feels synchronous — the behavior is not.

That mismatch creates a common trap: code looks easy to reason about line-by-line, but runtime behavior depends on suspension boundaries and executor context.


Why this is confusing

Before async/await, asynchronous Swift code was explicit: closures, callbacks, dispatch queues.

With structured concurrency, Swift deliberately hides those mechanics. The code reads like a normal function, while the execution model silently changes.

Without a mental model, it’s easy to:

  • assume threads are blocked
  • misunderstand suspension points
  • misuse async code inside views or models

It is also easy to translate old completion-handler habits directly into Task {} blocks everywhere. That can work initially, but it often leads to scattered cancellation handling and duplicated loading logic. The syntax changed, yet the architecture remained callback-shaped.


A better mental model

Async functions don’t block.
They suspend and resume.

When Swift reaches an await, the function:

  • yields control
  • remembers its state
  • resumes later when the awaited value is ready

The function behaves like a paused computation, not a running thread.

This keeps code readable and efficient.

Another useful extension of this model: await marks a potential pause point where control may leave your function and come back later. So everything around an await boundary should be written with that pause/resume reality in mind.


A small proof

func loadUser() async -> User {
    let user = await api.fetchUser()
    return user
}

fetchUser() does not stall the system. Swift suspends loadUser(), runs other work, and resumes when data arrives.

Nothing blocks. Nothing spins.

Now compare that to UI usage:

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published private(set) var state: State = .idle

    enum State {
        case idle
        case loading
        case loaded(User)
        case failed(String)
    }

    func load() async {
        state = .loading
        do {
            let user = try await api.fetchUser()
            state = .loaded(user)
        } catch {
            state = .failed("Could not load profile.")
        }
    }
}

This is not about syntax sugar. It is about modeling async transitions explicitly in state.


Why this matters in real apps

Once you think in terms of suspension:

  • async code becomes predictable
  • UI remains responsive
  • concurrency becomes structured

You stop guarding threads and start reasoning about flow.

This shift improves design decisions: you place loading state transitions where ownership is clear, you make cancellation part of feature behavior, and you reduce accidental race conditions caused by loose callback chains. Teams also benefit in reviews, because suspension points are explicit and test scenarios become easier to define.


Where this model breaks down

Async code still needs care:

  • shared mutable state
  • cancellation
  • actor boundaries

Suspension is cheap — unsafe access is not.

If multiple async tasks can update the same mutable state without coordination, you can still get inconsistent UI. Likewise, if cancellation is ignored, old requests may finish late and overwrite newer user intent. async/await makes these problems easier to express, but it does not solve them automatically.

The model works best when paired with explicit ownership and state transitions.


One sentence to remember

async/await describes execution flow, not thread usage.

Next steps

If you want to connect concurrency flow with SwiftUI architecture, these are the best follow-ups: