Swift
async/await
completion handlers
concurrency
programming

Swift how to wrap completion into an async/await?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

When you have an older Swift API that uses a completion handler, the usual way to expose it as async and await is to wrap it with a continuation. Swift provides withCheckedContinuation for non-throwing results and withCheckedThrowingContinuation for throwing ones. The key rule is simple: resume the continuation exactly once.

Wrap a Simple Completion Handler

Suppose you start with an old-style API:

swift
1func loadUsername(completion: @escaping (String) -> Void) {
2    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
3        completion("mina")
4    }
5}

You can expose it as async like this:

swift
1func loadUsername() async -> String {
2    await withCheckedContinuation { continuation in
3        loadUsername { value in
4            continuation.resume(returning: value)
5        }
6    }
7}

Now callers can write:

swift
1Task {
2    let username = await loadUsername()
3    print(username)
4}

This is the standard migration pattern for non-throwing completion callbacks.

Wrap a Throwing Completion Handler

If the original API returns a result-or-error pair, use withCheckedThrowingContinuation.

swift
1enum APIError: Error {
2    case notFound
3}
4
5func fetchUser(id: Int, completion: @escaping (Result<String, Error>) -> Void) {
6    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
7        if id == 1 {
8            completion(.success("mina"))
9        } else {
10            completion(.failure(APIError.notFound))
11        }
12    }
13}
14
15func fetchUser(id: Int) async throws -> String {
16    try await withCheckedThrowingContinuation { continuation in
17        fetchUser(id: id) { result in
18            continuation.resume(with: result)
19        }
20    }
21}

And then call it like:

swift
1Task {
2    do {
3        let user = try await fetchUser(id: 1)
4        print(user)
5    } catch {
6        print(error)
7    }
8}

This is usually the cleanest way to bridge callback-based APIs into Swift concurrency.

Choose the Right Continuation Type

Use withCheckedContinuation when:

  • the completion never reports an error
  • the async function should not throw

Use withCheckedThrowingContinuation when:

  • the completion can fail
  • the async wrapper should propagate errors

The checked variants are usually preferable because Swift can diagnose misuse during development more helpfully than the unsafe variants.

Resume Exactly Once

This is the most important correctness rule. A continuation must be resumed once and only once.

Wrong patterns include:

  • forgetting to resume on an error path
  • resuming twice because multiple callbacks fire
  • returning before the callback ever happens

If the wrapped API is buggy or can call its completion multiple times, you need extra protection before bridging it into async and await.

Cancellation Is Not Automatic

Wrapping a completion handler does not automatically make the underlying work cancellable. If the old API supports cancellation, you need to model that explicitly.

For example, an async wrapper may still wait for work that continues in the background even after the task is cancelled. Bridging callback code into Swift concurrency improves the call site, but it does not magically redesign the underlying API.

That distinction matters in network and long-running operations.

Common Pitfalls

The biggest mistake is resuming the continuation more than once. Swift concurrency assumes the bridge is well behaved, and double resume is a serious bug.

Another common issue is choosing the non-throwing continuation for an API that can fail. If errors exist, model them as throws.

Developers also sometimes wrap a callback that can never fire under some conditions. That leaves the awaiting task suspended forever.

Finally, do not confuse "wrapped in async" with "now cancellation-safe and structured." The wrapper improves ergonomics, but cancellation and resource cleanup still need deliberate design.

Summary

  • Wrap callback-based APIs with withCheckedContinuation or withCheckedThrowingContinuation.
  • Use the throwing form when the original completion can fail.
  • Resume the continuation exactly once.
  • Expose the wrapped function as a normal async or async throws API.
  • Remember that cancellation behavior does not become correct automatically just because the call site now uses await.

Course illustration
Course illustration

All Rights Reserved.