Swift Concurrency has fundamentally changed how we write asynchronous code, making it more readable and safer. However, the real world is still full of legacy APIs and SDKs that rely on completion handlers and delegates. You cannot simply rewrite every library overnight. This is where Continuations come in. They act as a powerful bridge, allowing us to wrap older asynchronous patterns into modern async functions, ensuring that our codebases remain clean and consistent even when dealing with legacy code. The Challenge of Traditional Async Patterns For years, iOS developers relied on two fundamental approaches for asynchronous operations: completion closures and delegate callbacks. Consider a typical network request using completion handlers: func fetchUserData(completion: @escaping (User?, Error?) -> Void) { URLSession.shared.dataTask(with: url) { data, response, error in // Handle response in a different scope if let error = error { completion(nil, error) return } // Process data... completion(user, nil) }.resume() } Copy func fetchUserData(completion: @escaping (User?, Error?) -> Void) { URLSession.shared.dataTask(with: url) { data, response, error in // Handle response in a different scope if let error = error { completion(nil, error) return } // Process data... completion(user, nil) }.resume() } Copy Similarly, delegate patterns scatter logic across multiple methods: class LocationManager: NSObject, CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { // Handle success in one method } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { // Handle failure in another method } } Copy class LocationManager: NSObject, CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { // Handle success in one method } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { // Handle failure in another method } } Copy Both approaches share a critical weakness: they fragment your program’s control flow. Instead of reading code from top to bottom, developers must mentally jump between closures, delegate methods, and completion callbacks. This cognitive overhead breeds subtle bugs-forgetting to invoke a completion handler, calling it multiple times, or losing track of error paths through nested callbacks. Bridging the Gap with Async/Await Continuations transform these fragmented patterns into linear, readable code. They provide the missing link between callback-based APIs and Swift’s structured concurrency model. By wrapping legacy asynchronous operations, you can write code that suspends at natural points and resumes when results arrive-without modifying the underlying implementation. Here’s the transformation in action. Our callback-based network function becomes: func fetchUserData() async throws -> User { try await withCheckedThrowingContinuation { continuation in URLSession.shared.dataTask(with: url) { data, response, error in if let error = error { continuation.resume(throwing: error) return } // Process and resume with result continuation.resume(returning: user) }.resume() } } Copy func fetchUserData() async throws -> User { try await withCheckedThrowingContinuation { continuation in URLSession.shared.dataTask(with: url) { data, response, error in if let error = error { continuation.resume(throwing: error) return } // Process and resume with result continuation.resume(returning: user) }.resume() } } Copy Now calling code flows naturally: do { let user = try await fetchUserData() let profile = try await fetchProfile(for: user) updateUI(with: profile) } catch { showError(error) } do { let user = try await fetchUserData() let profile = try await fetchProfile(for: user) updateUI(with: profile) } catch { showError(error) } Understanding Continuation Mechanics A continuation represents a frozen moment in your program’s execution. When you mark a suspension point with await, Swift doesn’t simply pause and wait, it captures the entire execution context into a lightweight continuation object. This includes local variables, the program counter, and the call stack state. This design enables Swift’s runtime to operate efficiently. Rather than dedicating one thread per asynchronous operation (the traditional approach that leads to thread explosion), the concurrency system maintains a thread pool sized to match your CPU cores. When a task suspends, its thread becomes available for other work. When the task is ready to resume, the runtime uses any available thread to reconstruct the execution state from the continuation. Consider what happens during a network call: func processData() async throws { let config = loadConfiguration() // Runs immediately let data = try await downloadData() // Suspends here let result = transform(data, with: config) // Resumes here return result } Copy func processData() async throws { let config = loadConfiguration() // Runs immediately let data = try await downloadData() // Suspends here let result = transform(data, with: config) // Resumes here return result } Copy At the await point, Swift creates a continuation capturing config and the program location. The current thread is freed for other tasks. When downloadData() completes, the runtime schedules resumption—but not necessarily on the same thread. The continuation ensures all local state travels with the execution, making thread switching transparent. await downloadData() Manual Continuation Creation Swift provides two continuation variants, each addressing different needs: CheckedContinuation performs runtime validation, detecting common errors like resuming twice or forgetting to resume. This safety net makes it the default choice during development: CheckedContinuation performs runtime validation, detecting common errors like resuming twice or forgetting to resume. This safety net makes it the default choice during development: CheckedContinuation func getCurrentLocation() async throws -> CLLocation { try await withCheckedThrowingContinuation { continuation in let manager = CLLocationManager() manager.requestLocation() manager.locationHandler = { locations in if let location = locations.first { continuation.resume(returning: location) } } manager.errorHandler = { error in continuation.resume(throwing: error) } } } func getCurrentLocation() async throws -> CLLocation { try await withCheckedThrowingContinuation { continuation in let manager = CLLocationManager() manager.requestLocation() manager.locationHandler = { locations in if let location = locations.first { continuation.resume(returning: location) } } manager.errorHandler = { error in continuation.resume(throwing: error) } } } If you accidentally resume twice, you’ll see a runtime warning: SWIFT TASK CONTINUATION MISUSE: continuation resumed multiple times. SWIFT TASK CONTINUATION MISUSE: continuation resumed multiple times. UnsafeContinuation removes these checks for maximum performance. Use it only in hot paths where profiling confirms the overhead matters, and you’ve thoroughly verified correctness: UnsafeContinuation removes these checks for maximum performance. Use it only in hot paths where profiling confirms the overhead matters, and you’ve thoroughly verified correctness: UnsafeContinuation func criticalOperation() async -> Result { await withUnsafeContinuation { continuation in performHighFrequencyCallback { result in continuation.resume(returning: result) } } } func criticalOperation() async -> Result { await withUnsafeContinuation { continuation in performHighFrequencyCallback { result in continuation.resume(returning: result) } } } Working with Continuation Resume Methods The continuation API enforces a strict contract: resume exactly once. This guarantee prevents resource leaks and ensures predictable execution. Swift provides four resume methods to cover different scenarios: resume() for operations without return values: resume() for operations without return values: resume() func waitForAnimation() async { await withCheckedContinuation { continuation in UIView.animate(withDuration: 0.3, animations: { self.view.alpha = 0 }) { _ in continuation.resume() } } } func waitForAnimation() async { await withCheckedContinuation { continuation in UIView.animate(withDuration: 0.3, animations: { self.view.alpha = 0 }) { _ in continuation.resume() } } } resume(returning:) to provide a result: resume(returning:) to provide a result: resume(returning:) func promptUser(message: String) async -> Bool { await withCheckedContinuation { continuation in let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Yes", style: .default) { _ in continuation.resume(returning: true) }) alert.addAction(UIAlertAction(title: "No", style: .cancel) { _ in continuation.resume(returning: false) }) present(alert, animated: true) } } func promptUser(message: String) async -> Bool { await withCheckedContinuation { continuation in let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Yes", style: .default) { _ in continuation.resume(returning: true) }) alert.addAction(UIAlertAction(title: "No", style: .cancel) { _ in continuation.resume(returning: false) }) present(alert, animated: true) } } resume(throwing:) for error propagation: resume(throwing:) for error propagation: resume(throwing:) func authenticateUser() async throws -> User { try await withCheckedThrowingContinuation { continuation in authService.login { result in switch result { case .success(let user): continuation.resume(returning: user) case .failure(let error): continuation.resume(throwing: error) } } } } func authenticateUser() async throws -> User { try await withCheckedThrowingContinuation { continuation in authService.login { result in switch result { case .success(let user): continuation.resume(returning: user) case .failure(let error): continuation.resume(throwing: error) } } } } resume(with:) as a convenient shorthand for Result types: resume(with:) as a convenient shorthand for Result types: resume(with:) func loadImage(from url: URL) async throws -> UIImage { try await withCheckedThrowingContinuation { continuation in imageLoader.fetch(url) { result in continuation.resume(with: result) } } } func loadImage(from url: URL) async throws -> UIImage { try await withCheckedThrowingContinuation { continuation in imageLoader.fetch(url) { result in continuation.resume(with: result) } } } Practical Integration Patterns When migrating real-world code, certain patterns emerge repeatedly. Here’s how to handle a delegate-based API with multiple possible outcomes: class NotificationPermissionManager: NSObject, UNUserNotificationCenterDelegate { func requestPermission() async throws -> Bool { try await withCheckedThrowingContinuation { continuation in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in if let error = error { continuation.resume(throwing: error) } else { continuation.resume(returning: granted) } } } } } class NotificationPermissionManager: NSObject, UNUserNotificationCenterDelegate { func requestPermission() async throws -> Bool { try await withCheckedThrowingContinuation { continuation in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in if let error = error { continuation.resume(throwing: error) } else { continuation.resume(returning: granted) } } } } } For callbacks that might never fire (like user cancellation), ensure you handle all paths: func selectPhoto() async -> UIImage? { await withCheckedContinuation { continuation in let picker = UIImagePickerController() picker.didSelect = { image in continuation.resume(returning: image) } picker.didCancel = { continuation.resume(returning: nil) } present(picker, animated: true) } } func selectPhoto() async -> UIImage? { await withCheckedContinuation { continuation in let picker = UIImagePickerController() picker.didSelect = { image in continuation.resume(returning: image) } picker.didCancel = { continuation.resume(returning: nil) } present(picker, animated: true) } } Conclusion Continuations represent more than a compatibility layer they embody Swift’s pragmatic approach to evolution. By providing clean integration between legacy and modern patterns, they enable gradual migration rather than forcing disruptive rewrites. As you encounter older APIs in your codebase, continuations offer a path forward that maintains both backward compatibility and forward-looking code quality. The safety guarantees of CheckedContinuation make experimentation low-risk, while UnsafeContinuation provides an escape hatch for proven, performance-critical code. Master these tools, and you’ll find that even the most callback-laden legacy code can integrate seamlessly into modern async workflows. CheckedContinuation UnsafeContinuation