iosNudge

What are continuations, and how do you wrap a callback API with async/await?

MediumConcurrency 2 min read

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

MistakeWhat happens
Never resumeThe task waits forever. Checked continuations log a warning ("leaked its continuation")
Resume twiceCrash 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...ContinuationwithUnsafe...Continuation
Checks misuse at run timeYesNo
CostSmallSmallest
UseDefault, always start hereOnly 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.