async/await vs completion handlers

Concurrency Junior Swift 5.5+ iOS 15+ Reviewed By Alok Singh

Pick the fix

Callers sometimes wait forever. Which change removes this whole class of bug?

func loadUser(id: String, completion: @escaping (User?) -> Void) {
    guard !id.isEmpty else { return }
    api.fetchUser(id) { user in
        completion(user)
    }
}
Options
Hint 1 of 2

Look at the guard line: what does the caller get?

Hint 2 of 2

Which style lets the compiler check every exit path?

Choose an answer, then open Show answer below to check it.

Show answer

Answer: A Make it func loadUser(id: String) async throws -> User and throw on the empty id

Correct. Every path must now return or throw, and the compiler enforces it.

Why not the others?
  • B Add completion(nil) inside the guard. That fixes this one path, but the next early return can repeat the bug; nothing checks it.
  • C Call completion inside DispatchQueue.main.async. That changes which thread the callback runs on, not whether it runs.
  • D Mark the closure @Sendable. That's about thread safety of captured values, not about the callback being called.

30-second answer

async/await lets asynchronous code read top to bottom like synchronous code. The compiler checks that every path returns a value or throws, errors use normal try/catch, and cancellation flows through structured tasks. With completion handlers, forgetting to call the callback on one path is a silent bug the compiler can't catch.

Practise saying it · 45 s. No recording, just a timer

Explain why async/await is safer than completion handlers.

Say your answer out loud, then check which of these points you covered:

  • The compiler checks every path returns or throws
  • Errors use try/catch instead of callbacks
  • Structured tasks give cancellation
  • async means 'can suspend', not 'background thread'

Deep dive

An async function can suspend at each await. While it is suspended, the thread is free for other work; no thread is blocked.

Errors propagate with throws, so there is no Result plumbing or nested if let error checks.

Tasks form a tree. Cancelling a parent cancels its children, which callbacks can't express.

Bridge old callback APIs with withCheckedThrowingContinuation, and resume the continuation exactly once.

Bridging a callback API Type-checked · Swift 6.4
func loadUser(id: String) async throws -> User {
    try await withCheckedThrowingContinuation { continuation in
        api.fetchUser(id) { result in
            continuation.resume(with: result)   // exactly once
        }
    }
}

Common mistakes

  • Thinking async means 'runs on a background thread'. It only means the function can suspend.
  • Resuming a continuation twice, or never, when wrapping a callback API.
  • Wrapping every call in Task {} instead of making the caller async.

Senior take & follow-ups

Strong answers talk about correctness, not just syntax: the compiler now proves every path completes, which removes a whole class of hung-UI bugs.

Follow-up questions

Think of your answer first, then open the hint.

  1. How do you wrap a delegate-based API that may call back more than once?

    Show hint

    A continuation resumes once; use AsyncStream for repeated values.

  2. What happens to the thread while a function is suspended at an await?

    Show hint

    It is returned to the pool and runs other work.

Suggest a fix if something here is wrong or out of date.