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

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

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

Now calling code flows naturally:

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

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.

Manual Continuation Creation

Swift provides two continuation variants, each addressing different needs:

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.

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:

func waitForAnimation() async {
    await withCheckedContinuation { continuation in
        UIView.animate(withDuration: 0.3, animations: {
            self.view.alpha = 0
        }) { _ in
            continuation.resume()
        }
    }
}

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 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 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)
                }
            }
        }
    }
}

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)
    }
}

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.