← All shorts
53Swift 5.5+2 min read

async/await: Replacing Completion Handlers

async/await turns nested completion handler pyramids into linear, readable code the compiler can check.

Completion handler chains for sequential async work nest closures inside closures, and every one of them needs its own error branch. Nothing forces you to call the handler exactly once, so double-calls and silent drops are easy to ship.

#The old way

func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
    URLSession.shared.dataTask(with: userURL(for: id)) { data, response, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        guard let data = data else {
            completion(.failure(URLError(.badServerResponse)))
            return
        }
        do {
            let user = try JSONDecoder().decode(User.self, from: data)
            completion(.success(user))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}

#The new way

struct User: Decodable {
    let id: String
    let name: String
}

func userURL(for id: String) -> URL {
    URL(string: "https://api.example.com/users/\(id)")!
}

func fetchUser(id: String) async throws -> User {
    let (data, response) = try await URLSession.shared.data(from: userURL(for: id))
    guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }
    return try JSONDecoder().decode(User.self, from: data)
}

func loadProfile(userID: String) async throws -> User {
    let user = try await fetchUser(id: userID)
    return user
}

#Why it matters

  • Sequential steps read top to bottom instead of nesting one closure per step
  • throws replaces Result and manual error propagation with normal do/catch
  • The compiler enforces that an async function either returns a value or throws — no forgotten completion call
  • Call sites use ordinary control flow (if, for, guard) around awaited calls instead of threading state through closures

#Gotcha

Marking a function async doesn't make it run on a background thread by default — it runs on whatever executor its caller is on, and blocking work inside it (heavy synchronous parsing, file I/O without an async API) still blocks that executor. async describes suspension points, not concurrency; you still need to reach for Task.detached or a dedicated executor if you actually need to move CPU-bound work off the caller's context.

swiftconcurrency

Related shorts