Advanced Concurrency

Continuations: Callback to Async

August 2, 2026
5 min read
Featured image for blog post: Continuations: Callback to Async

Legacy codebases are full of completion handlers. Continuations let you wrap them as async functions without rewriting everything.

Follow along with the code: iOS-Practice on GitHub

The Basic Pattern

// Old callback API
func fetchUser(id: Int, completion: @escaping (User?) -> Void)

// Wrapped as async
func fetchUser(id: Int) async -> User? {
    await withCheckedContinuation { continuation in
        fetchUser(id: id) { user in
            continuation.resume(returning: user)
        }
    }
}

Now you can call it cleanly:

let user = await fetchUser(id: 42)

The Critical Rule

Resume exactly once. Not zero times. Not twice. Once.

// CRASH: Never resumed
await withCheckedContinuation { continuation in
    if condition {
        continuation.resume(returning: value)
    }
    // What if condition is false? Hangs forever!
}

// CRASH: Resumed twice
await withCheckedContinuation { continuation in
    continuation.resume(returning: value1)
    continuation.resume(returning: value2)  // Fatal error!
}

Checked vs Unchecked

// Development: validates single resume
await withCheckedContinuation { continuation in
    // Warns/crashes on misuse
}

// Production: no validation, slightly faster
await withUnsafeContinuation { continuation in
    // You're on your own
}

Use withCheckedContinuation during development. It catches bugs. Switch to withUnsafeContinuation only for performance-critical code after thorough testing.

Throwing Version

For APIs that can fail:

func fetchData() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        legacyFetch { result in
            switch result {
            case .success(let data):
                continuation.resume(returning: data)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

Handling All Code Paths

Every path must resume:

func fetchWithTimeout() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        var didResume = false

        // Success path
        api.fetch { data in
            guard !didResume else { return }
            didResume = true
            continuation.resume(returning: data)
        }

        // Timeout path
        DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
            guard !didResume else { return }
            didResume = true
            continuation.resume(throwing: TimeoutError())
        }
    }
}

The didResume flag ensures exactly one resume.

URLSession Example

Older iOS versions without async URLSession:

extension URLSession {
    func data(from url: URL) async throws -> (Data, URLResponse) {
        try await withCheckedThrowingContinuation { continuation in
            let task = self.dataTask(with: url) { data, response, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else if let data = data, let response = response {
                    continuation.resume(returning: (data, response))
                } else {
                    continuation.resume(throwing: URLError(.unknown))
                }
            }
            task.resume()
        }
    }
}

When to Use Continuations vs AsyncStream

SituationUse
Single callback (one result)Continuation
Multiple callbacks (stream of results)AsyncStream
Delegate with one methodContinuation
Delegate with ongoing eventsAsyncStream

Interview Tip

Continuations are the bridge between old and new. When asked how to adopt async/await in a legacy codebase, explain: wrap existing completion handlers with continuations incrementally—no need to rewrite everything at once.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch