What are continuations, and how do you wrap a callback API with async/await?
TL;DR
A continuation lets you turn a callback-based API into an async function.
You call withCheckedContinuation (or the throwing version). Inside, you start the old API. When its callback fires, you call continuation.resume(...).
Rule: resume exactly once. Never zero times, never twice.
Wrapping a completion handler
The old API:
func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
// network work...
}The async wrapper:
func fetchUser(id: Int) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
fetchUser(id: id) { result in
continuation.resume(with: result)
}
}
}
// Usage
let user = try await fetchUser(id: 42)resume(with:) takes a Result. You can also use resume(returning:) and resume(throwing:).
Non-throwing version
func currentPermission() async -> Bool {
await withCheckedContinuation { continuation in
LegacyPermissions.check { granted in
continuation.resume(returning: granted)
}
}
}The "exactly once" rule
| Mistake | What happens |
|---|---|
| Never resume | The task waits forever. Checked continuations log a warning ("leaked its continuation") |
| Resume twice | Crash with checked continuations. Undefined behavior with unsafe ones |
A common bug: an early return path that forgets to resume.
func load() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
legacyLoad { data, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let data else {
continuation.resume(throwing: LoadError.empty) // do not forget this path
return
}
continuation.resume(returning: data)
}
}
}Checked vs unsafe
withChecked...Continuation | withUnsafe...Continuation | |
|---|---|---|
| Checks misuse at run time | Yes | No |
| Cost | Small | Smallest |
| Use | Default, always start here | Only hot paths, after testing well |
The checked and unsafe versions have the same API. You can switch later.
Wrapping a delegate (one-shot)
Store the continuation, and resume it in the delegate method.
@MainActor
final class PhotoPicker: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
private var continuation: CheckedContinuation<UIImage?, Never>?
func pick(from presenter: UIViewController) async -> UIImage? {
await withCheckedContinuation { continuation in
self.continuation = continuation
let picker = UIImagePickerController()
picker.delegate = self
presenter.present(picker, animated: true)
}
}
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true)
continuation?.resume(returning: info[.originalImage] as? UIImage)
continuation = nil // prevents a second resume
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
continuation?.resume(returning: nil)
continuation = nil
}
}Setting continuation = nil after resuming protects against a double resume. Also handle the case where pick is called again while one is still waiting.
Continuation vs AsyncStream
- Continuation: one result. "Wait for this callback once."
- AsyncStream: many results. "Give me every location update."
If the callback can fire many times, a continuation is wrong: the second call would resume twice and crash.
Cancellation
A continuation does not know about task cancellation. If the task is cancelled, the old API keeps running and you still must resume. Combine it with a cancellation handler:
func download(_ url: URL) async throws -> Data {
let request = LegacyRequest(url: url)
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
request.start { result in continuation.resume(with: result) }
}
} onCancel: {
request.cancel() // the legacy API then calls back with a cancel error
}
}Common follow-up questions
- Can I resume from any thread? Yes. The continuation is
Sendable. The async function continues on its own executor. - What happens to the thread while waiting? Nothing is blocked. The task is suspended, and the thread runs other work.
- Why not use a semaphore to wait for the callback? It blocks a thread from the cooperative pool and can deadlock. Continuations suspend instead.
- Do Apple APIs need this? Many already have async versions (like
URLSession.data(from:)). Use continuations for your own or third-party callback APIs. - Can I store a continuation and resume it much later? Yes, but make sure every path resumes it once, including errors, timeouts and deinit.