Chapter 7 — Continuations: bridging the callback world
The shape of the problem
// The old world: a delegate that calls back later. You can't `await` this.
locationProvider.requestLocation() // returns immediately
// … sometime later, one of these delegate methods fires:
func locationProvider(_ p: LocationProvider, didGet location: CLLocation) { … }
func locationProvider(_ p: LocationProvider, didFail error: Error) { … }
let location = try await locationProvider.currentLocation() // clean async
What a continuation is
flowchart LR
A["async func calls
withCheckedContinuation"] --> B["function SUSPENDS,
you get a continuation"] B --> C["you kick off the
callback-based work"] C --> D["callback fires later"] D --> E["continuation.resume(returning:)"] E --> F["async func RESUMES,
returns the value"]
withCheckedContinuation"] --> B["function SUSPENDS,
you get a continuation"] B --> C["you kick off the
callback-based work"] C --> D["callback fires later"] D --> E["continuation.resume(returning:)"] E --> F["async func RESUMES,
returns the value"]
Bridging a completion handler
// Legacy API we can't change:
func legacyFetch(_ id: Int, completion: @escaping (Result<Payload, Error>) -> Void)
// Our async bridge:
func fetchPayload(_ id: Int) async throws -> Payload {
try await withCheckedThrowingContinuation { continuation in
legacyFetch(id) { result in
switch result {
case .success(let payload): continuation.resume(returning: payload)
case .failure(let error): continuation.resume(throwing: error)
}
}
}
}
The one rule: resume exactly once
You must call
resumeon a continuation exactly once. Not zero times. Not twice. Exactly once.
- Resume zero times → the async function suspends forever. It's waiting for a
resumethat never - Resume twice → undefined behavior. The function already resumed and returned; resuming again
Bridging a delegate (the hard case)
// LocationOnce/LocationProvider.swift
import CoreLocation
final class OneShotLocation: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLLocation, Error>?
func currentLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
// Store it so the delegate methods can resume it.
self.continuation = continuation
manager.delegate = self
manager.requestLocation() // fires a delegate method later
}
}
func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) {
guard let location = locs.last else { return }
continuation?.resume(returning: location)
continuation = nil // ← clear it: never resume twice
}
func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {
continuation?.resume(throwing: error)
continuation = nil // ← clear it here too
}
}
Isolation note: storing a continuation in a property and resuming it from a delegate callback means the continuation crosses between contexts. In Swift 6 you'll often make the bridging type an
actoror@MainActor(like this one effectively is, sinceCLLocationManagerwants the main thread) so the compiler is satisfied that the storedcontinuationisn't touched from two places at once. We'll formalize this in Part III; for now, note that these bridges usually live on a single actor.
Continuations and cancellation
func cancellableFetch(_ id: Int) async throws -> Payload {
let handle = LegacyHandle()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
handle.start(id) { result in
continuation.resume(with: result)
}
}
} onCancel: {
handle.cancel() // makes the legacy API call back with a cancellation error,
// which resumes the continuation — still exactly once
}
}
When you have many callbacks: use a stream instead
flowchart LR
Q{"How many times does
the callback fire?"} Q -->|"Once (one result)"| C["Continuation (this chapter)"] Q -->|"Many (a stream)"| S["AsyncStream (Chapter 8)"]
the callback fire?"} Q -->|"Once (one result)"| C["Continuation (this chapter)"] Q -->|"Many (a stream)"| S["AsyncStream (Chapter 8)"]
What we built in this chapter
- Bridged the old callback world into async/await with continuations:
withCheckedThrowingContinuation - Learned the one rule — resume exactly once — and its two failure modes: **zero resumes hang the
- Bridged a delegate (the hard case) by storing the continuation and clearing it after resume,
- Wired cancellation through
withTaskCancellationHandler, routing cancellation through the legacy - Drew the line: continuations bridge a single callback; a repeating callback needs an
Mental model to take away
- A continuation is a handle to a suspended async function; you resume it to make the
await - Resume exactly once — zero hangs the task silently, twice crashes. Use the checked variants so
- Wrap the bridge in
withTaskCancellationHandlerto make it cancellable, keeping a single resume - One callback → continuation; many callbacks →
AsyncStream. Don't force a continuation to do