How do continuations bridge callback APIs, and what is the difference between checked and unsafe?

10.6.1 · L3 · Interview

Predict first

A callback-based function is wrapped with a checked continuation. What does the caller get?

import Dispatch

func legacy(_ done: @escaping @Sendable (Int) -> Void) { DispatchQueue.global().async { done(42) } }
print(await withCheckedContinuation { c in legacy { c.resume(returning: $0) } })
  • 42
  • Nothing, it hangs
  • It does not compile

Predict first

A callback that is called twice resumes a checked continuation twice. What happens?

func legacy(_ done: @escaping @Sendable (Int) -> Void) { done(1); done(2) }
let v = await withCheckedContinuation { c in legacy { c.resume(returning: $0) } }
print("got", v)
  • It prints got 1
  • It prints got 2
  • The process crashes: resumed more than once

Mental model. A continuation is a reply-slip handed to a stranger. They must return it exactly once. Checked mode is a clerk who catches a second reply or a lost slip; unsafe mode trusts you.

A continuation lets an async function wait for a callback: await withCheckedContinuation { c in legacy { c.resume(returning: $0) } } (or withCheckedThrowingContinuation with resume(throwing:) and resume(with: Result)). The task suspends until some code calls resume, then continues with that value. The contract is strict: you must resume exactly once on every path, including the failure and cancellation ones. Resuming twice or never is a bug, and the two variants differ in how they tell you. withCheckedContinuation (the default choice) detects misuse: resuming a second time is a fatal error (“tried to resume its continuation more than once”), and a continuation dropped without resuming logs a leak warning (and the task would wait forever). withUnsafeContinuation does the same job with no checks and slightly less overhead: a double resume is undefined behaviour and a missing one is a silent hang. Use checked while developing and in nearly all production code; reach for unsafe only where you have measured the cost.

Design points. Capture the continuation in the callback and make sure the callback is called exactly once by the API’s contract; if it can be called many times, use a stream instead. Handle the “delegate was never called” and “object deallocated before completion” cases (resume with an error in deinit or on cancellation, using withTaskCancellationHandler). Do not resume while holding a lock the continuation’s waiter needs. The function you bridge should be @Sendable-safe: the callback often runs on another thread, so the continuation (which is Sendable) is the only thing it should touch. Prefer wrapping an old API once, in an extension, so the rest of the code never sees the callback.

The callback runs once, resumes the continuation with 42, and the awaiting task continues with it.

Checked continuations enforce “exactly once”, and a second resume is a fatal error.

Common traps

  • Not resuming on an error or early-return path. The caller waits forever.
  • Resuming twice, for example from both a success and a timeout handler. Checked mode crashes; unsafe mode corrupts state.

They might follow up with

  • What if the callback can run several times? Use an AsyncStream, not a continuation.
  • How do you handle cancellation of the wrapped operation? withTaskCancellationHandler, cancelling the underlying request in onCancel.

Related questions

  • 10.6.2 How do you bridge repeated events, GCD and Combine into async code with AsyncStream?
  • 10.3.3 How does cancellation work in Swift concurrency, and what are task priorities and task locals for?
  • 10.7.1 How do you find and fix deadlocks, data races and slow main-actor hops?

How do you bridge repeated events, GCD and Combine into async code… →