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
| Situation | Use |
|---|---|
| Single callback (one result) | Continuation |
| Multiple callbacks (stream of results) | AsyncStream |
| Delegate with one method | Continuation |
| Delegate with ongoing events | AsyncStream |
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.