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"]

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 resume on a continuation exactly once. Not zero times. Not twice. Exactly once.

  • Resume zero times → the async function suspends forever. It's waiting for a resume that 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 actor or @MainActor (like this one effectively is, since CLLocationManager wants the main thread) so the compiler is satisfied that the stored continuation isn'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)"]

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 withTaskCancellationHandler to make it cancellable, keeping a single resume
  • One callback → continuation; many callbacks → AsyncStream. Don't force a continuation to do