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:
CheckedContinuationperforms runtime validation, detecting common errors like resuming twice or forgetting to resume. This safety net makes it the default choice during development:
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.
UnsafeContinuationremoves these checks for maximum performance. Use it only in hot paths where profiling confirms the overhead matters, and you’ve thoroughly verified correctness:
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:
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:
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:
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:
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.
