- Published on
- 6 min read Intermediate
> withCheckedContinuation vs withUnsafeContinuation in Swift
Not every API you need to call has an async version yet. Delegate callbacks, NotificationCenter observers, C libraries, older Objective-C SDKs, the CLLocationManager you wrote a wrapper around in 2018: all of these want to hand you a result through a closure or a delegate method, and none of them know anything about await. Continuations are the bridge. You suspend the current task, hand a resume token to the legacy API, and when it fires its callback you resume the task with the result.
Swift gives you two flavors: withCheckedContinuation and withUnsafeContinuation, plus their throwing siblings withCheckedThrowingContinuation and withUnsafeThrowingContinuation. They look identical at the call site. The difference is how much the runtime is willing to catch you if you misuse them.
The One Rule
Every continuation must be resumed exactly once. Not zero times, not twice, exactly once.
Resume it zero times and the awaiting task hangs forever. There is no timeout. The task sits suspended, holding onto whatever it had captured, until the process dies. Resume it twice and you have a bug that ranges from "corrupted state" to "crash" depending on which variant you used and what the task was doing when the second resume arrived.
This rule is easy to state and surprisingly easy to break. Completion handlers that fire on both success and error paths, delegate methods that call each other, notification observers that keep firing after you thought you were done: all of these can lead to a second resume you did not plan for.
What Checked Actually Checks
withCheckedContinuation wraps the underlying continuation in a small runtime object that tracks whether resume has been called. If you resume a second time, it traps with a clear message pointing at the continuation. If the continuation gets deallocated without ever being resumed, the runtime logs a warning ("SWIFT TASK CONTINUATION MISUSE: leaked its continuation") so you notice the hang in development rather than staring at a silent stall.
The cost is a heap-allocated wrapper and some atomic bookkeeping. Measured against the kind of work you are usually bridging (network, disk, location services, IPC), this is in the noise.
withUnsafeContinuation skips all of it. No tracking, no warning, no trap. A double-resume is undefined behavior. A dropped continuation is a permanent hang with no diagnostic. The payoff is that there is nothing extra to allocate or synchronize, which is occasionally worth it in a tight loop over a proven-correct wrapper. Occasionally.
A Real Example
Here is a typical delegate bridge, wrapping a one-shot location fetch with CLLocationManager:
import CoreLocation
final class LocationFetcher: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLLocation, Error>?
func currentLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
manager.delegate = self
manager.requestLocation()
}
}
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
guard let location = locations.first else { return }
continuation?.resume(returning: location)
continuation = nil
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
}
The pattern is the shape of almost every continuation bridge you will write. Store the continuation, kick off the legacy work, resume from whichever callback fires, and null the stored reference so you cannot accidentally resume it again. The nil-out step is the one most people forget, and it is exactly what the next section is about.
How Double-Resume Sneaks In
Imagine the same wrapper without that continuation = nil line. requestLocation() is documented to deliver "one-shot" updates, which sounds safe, but you have no guarantee the system will not call didUpdateLocations more than once if conditions are unusual, and you have even less of a guarantee in tests where you are invoking these delegate methods yourself. A second call to didUpdateLocations would hit this line:
continuation?.resume(returning: location)
With withCheckedThrowingContinuation, you get a trap on that second call, with a message that says the continuation was resumed more than once. You see the crash during development, you add the nil-out, you move on.
With withUnsafeThrowingContinuation, the second resume is undefined behavior. Sometimes it silently corrupts the task's state. Sometimes it crashes later in code that looks unrelated. Sometimes nothing visible happens in debug and you ship the bug. The runtime had no hook to catch it because you asked it not to.
The same reasoning applies to the dropped-continuation case. If requestLocation quietly fails to call either delegate method (say, because the user revoked permission and the system is behaving in a way you did not anticipate), the task hangs. withCheckedContinuation gives you a console warning when the continuation is deallocated unresumed, which at least tells you where to look. The unsafe version gives you silence.
When To Use Which
Default to checked. Use it while you are writing the wrapper, use it in tests, use it in production, use it when you think you are sure the wrapper is right. The overhead is a rounding error next to any real async work, and you get two bugs caught for free: the double-resume that would otherwise be intermittent, and the dropped continuation that would otherwise be silent.
Reach for unsafe only when you have a measured, specific reason. That usually means a profiler has told you a specific continuation wrapper is a bottleneck, the wrapper is simple enough that you can be genuinely confident it is correct, and you are willing to audit it whenever it changes. In my own code this has basically never happened. The Swift team's own guidance in the Unsafe Continuation docs is that the checked version is "strongly preferred" during development and that unsafe is for tight performance-critical paths where the safety checks become noticeable.
One more thing worth noting: you can freely swap between the two without changing any other code. The UnsafeContinuation and CheckedContinuation types have the same resume API. If you want to develop with checked and profile with unsafe to see whether the difference is actually measurable for your workload, that is a five-character edit. In almost every case, it isn't, and you leave it checked.
Sample Project
Want to see this code in action? Check out the complete sample project on GitHub:
The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.
// Continue_Learning
Async/Await Basics in Swift
A practical introduction to async/await in Swift, covering the fundamentals you need to write concurrent code that's both safe and readable.
Async/Await vs GCD in Swift: When to Use Each
Both async/await and Grand Central Dispatch solve concurrency problems, but they approach them differently. Here's how to choose between them.
Data Races Swift 6 Still Can't Catch
Swift 6's data-race safety is real, but it has blind spots. Here are the places the compiler can't see, and how to stop treating a clean build as proof your code is thread safe.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.