iosNudge

How does async/await work in Swift?

MediumAsked oftenConcurrency 2 min read

TL;DR

async marks a function that can suspend (pause). await marks the places where it may pause. While it waits, the thread is not blocked. Swift runs other work on it. The code reads top to bottom like normal code. No completion handlers, no pyramid of closures, and errors use normal try.

Before and after

Completion handlers:

func loadProfile(completion: @escaping (Result<Profile, Error>) -> Void) {
    fetchUser { result in
        switch result {
        case .success(let user):
            fetchAvatar(for: user) { avatarResult in
                // more nesting, easy to forget calling completion
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

async/await:

func loadProfile() async throws -> Profile {
    let user = try await fetchUser()
    let avatar = try await fetchAvatar(for: user)
    return Profile(user: user, avatar: avatar)
}

Same logic, much clearer. The compiler also checks that every path returns or throws.

What "suspend" means

At an await, the function may give up its thread. Its local state is saved (on the heap). When the result is ready, the function resumes, maybe on a different thread.

func refresh() async {
    print("start")
    let data = await download()   // suspension point
    print("got \(data.count) bytes")  // may run on another thread
}

Do not assume the same thread after await, unless you are on an actor like @MainActor.

Calling async code

You can only await inside an async context. From sync code, start a Task:

final class ProfileViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        Task {
            let profile = try? await loadProfile()
            title = profile?.name      // Task inherits @MainActor here
        }
    }
}

In SwiftUI, use the .task modifier. It cancels the work when the view disappears.

.task {
    await model.load()
}

Running things in parallel

await one after another runs in sequence. Use async let to run in parallel.

func loadDashboard() async throws -> Dashboard {
    async let user = fetchUser()
    async let news = fetchNews()
    async let weather = fetchWeather()

    return try await Dashboard(user: user, news: news, weather: weather)
}

All three start at once. The total time is the slowest one, not the sum. For a dynamic number of tasks, use a task group.

The thread pool

Swift concurrency uses a cooperative thread pool, with about one thread per CPU core. Tasks share these threads by suspending at await.

Rule: never block a thread in async code.

// Bad: blocks a pool thread
let semaphore = DispatchSemaphore(value: 0)
semaphore.wait()

// Bad: long CPU work without suspension starves other tasks
for i in 0..<10_000_000 { heavy(i) }   // consider Task.yield() or a separate thread

Where does an async function run?

  • A function isolated to an actor (like @MainActor) runs on that actor.
  • In Swift 6.0 and 6.1, a nonisolated async function runs on the global concurrent pool.
  • In Swift 6.2 with approachable concurrency on (the default in new Xcode 26 projects), a nonisolated async function runs on the caller's actor. Add @concurrent to move it to the pool.
@concurrent
nonisolated func parse(_ data: Data) async throws -> [Item] {
    try JSONDecoder().decode([Item].self, from: data)
}

Bridging old APIs

Many Apple APIs already have async versions:

let (data, response) = try await URLSession.shared.data(from: url)

For your own callback APIs, use withCheckedThrowingContinuation.

Common follow-up questions

  • Does await always suspend? No. It marks a possible suspension. If the result is ready or no actor hop is needed, it may continue without pausing.
  • What is the difference between Task {} and Task.detached {}? Task {} inherits the current actor, priority and task-local values. Task.detached {} inherits nothing.
  • Is async let cancelled if I do not await it? Yes. When the scope ends, unawaited async let tasks are cancelled and then awaited implicitly.
  • Can a sync function call an async one and wait? Not safely. Blocking a thread to wait for async work can deadlock. Restructure the caller to be async or use a Task.
  • What is Task.yield()? It suspends the current task briefly so other tasks can run. Useful in long CPU loops.