How to use Continuations in Swift (withCheckedThrowingContinuation) | Modern Swift Concurrency #7
Most real-world iOS apps depend on third-party SDKs, legacy networking code, or system APIs (like CLLocationManager or AVAudioRecorder) that still use completion handlers — continuations are the bridge that lets you call those APIs with await, without rewriting them, so the rest of your codebase can use modern async/await throughout.
What You'll Learn
- What a continuation is and how
withCheckedThrowingContinuationsuspends the current task until a callback-based API calls back - The strict rule that every continuation must be resumed exactly once — what happens if you violate it (deadlock or crash)
- How
withCheckedContinuation(non-throwing) differs fromwithCheckedThrowingContinuation, and when to use each
Mental Model
A continuation is a bookmark in your code. When you call withCheckedThrowingContinuation, Swift takes a snapshot of exactly where the current task is — "I was here, waiting for this result" — and suspends the task. The continuation object is the bookmark. You pass this bookmark into the completion handler. When the completion handler fires (on whatever thread, at whatever point in the future), it calls continuation.resume(returning:) or continuation.resume(throwing:) to say "the task can continue from its bookmark now." The await expression evaluates to the returned value and execution proceeds.
The "checked" in withCheckedContinuation means Swift performs runtime checks: if you forget to resume the continuation, you get a runtime warning. If you resume it twice, you get a crash with a clear message. The unchecked variants (withUnsafeContinuation) skip these checks for performance — use them only after you've verified your code is correct with the checked versions.
Detailed Explanation
Continuations exist because Swift's async/await system and callback-based APIs occupy different conceptual models. async/await represents asynchronous work as a suspended task that resumes with a value. Completion handlers represent it as a function that is called when work finishes. These two models are incompatible at a direct call level — you can't await a function that takes a completion handler without a wrapper.
withCheckedThrowingContinuation is that wrapper. Its signature is:
func withCheckedThrowingContinuation<T>(
function: String = #function,
_ body: (CheckedContinuation<T, Error>) -> Void
) async throws -> TWhen you call it, the current task is suspended and the body closure is called synchronously with the continuation object. You pass the continuation into whatever callback-based API you're wrapping. When that callback fires, you call continuation.resume with either a value or an error. The continuation system then resumes the suspended task with that value as the result of the await expression.
The function parameter (defaulting to #function) is used in the runtime warning message if the continuation is never resumed — this is what makes it "checked." When you're debugging a hang, this function name tells you which continuation went missing.
There are four continuation types in Swift:
withCheckedContinuation— non-throwing, value-only, with runtime checkswithCheckedThrowingContinuation— throwing, can resume with value or error, with runtime checkswithUnsafeContinuation— non-throwing, no runtime checks (faster but less safe)withUnsafeThrowingContinuation— throwing, no runtime checks
Start with the checked variants. Only move to unsafe after profiling shows the overhead matters, and only after thoroughly testing the checked version.
A critical design rule: you must call continuation.resume exactly once in every possible code path. Look at getData2 in the sample — it has three branches: if let data, else if let error, and a final else. Every branch calls resume. If any branch were missing, the task would hang forever waiting for a resume that never comes (a deadlock). If any branch called resume twice, the second call would crash with a runtime error.
The getHeartImageFromDatabase overload pair demonstrates wrapping a custom completion-handler API (simulated by DispatchQueue.main.asyncAfter). Notice that withCheckedContinuation (non-throwing) is used here because the completion handler always succeeds — there is no error path. The original callback API is called inside the withCheckedContinuation closure, and the continuation is resumed in the callback.
Code Structure
CheckedContinuationBootcamp.swift contains CheckedContinuationBootcampNetworkManager with four methods: a direct async URL data fetch, a continuation-wrapped version of the same, and two versions of a heart-image fetch (callback-based and continuation-wrapped). The view model calls the continuation-wrapped versions so the view can use standard await. The .task modifier starts one of two fetches depending on which viewModel call is uncommented.
Complete Code
CheckedContinuationBootcamp.swift
import SwiftUI
class CheckedContinuationBootcampNetworkManager {
// Direct async approach: no continuation needed — URLSession.data is already async
func getData(url: URL) async throws -> Data {
do {
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
return data
} catch {
throw error
}
}
// Continuation-wrapped approach: same result as getData, but wraps URLSession's callback-based dataTask
// This is the pattern you'd use if URLSession didn't have an async version (e.g., a third-party SDK)
func getData2(url: URL) async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in // task suspends here; continuation is the resume handle
URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data {
continuation.resume(returning: data) // success path: resume with data
} else if let error = error {
continuation.resume(throwing: error) // error path: resume by throwing URLError
} else {
continuation.resume(throwing: URLError(.badURL)) // defensive case: neither data nor error (should not happen)
}
}
.resume() // URLSession's .resume() starts the network request — distinct from continuation.resume()
}
}
// Callback-based API (simulates a legacy or third-party SDK with a completion handler)
func getHeartImageFromDatabase(completionHandler: @escaping (_ image: UIImage) -> ()) {
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { // simulates a slow async operation
completionHandler(UIImage(systemName: "heart.fill")!) // always succeeds; no error path
}
}
// Async wrapper around the callback-based API using withCheckedContinuation (non-throwing, since there is no error path)
func getHeartImageFromDatabase() async -> UIImage {
await withCheckedContinuation { continuation in // non-throwing variant: no error path means no need for withCheckedThrowingContinuation
getHeartImageFromDatabase { image in // calls the callback version; continuation is captured by the closure
continuation.resume(returning: image) // resume exactly once: fires when the DispatchQueue callback arrives
}
}
}
}
class CheckedContinuationBootcampViewModel: ObservableObject {
@Published var image: UIImage? = nil
let networkManager = CheckedContinuationBootcampNetworkManager()
func getImage() async {
guard let url = URL(string: "https://picsum.photos/300") else { return }
do {
let data = try await networkManager.getData2(url: url) // calls the continuation-wrapped version
if let image = UIImage(data: data) {
await MainActor.run(body: {
self.image = image // hop to main actor before updating @Published property
})
}
} catch {
print(error) // URLError from the continuation's resume(throwing:) propagates here as a normal Swift error
}
}
func getHeartImage() async {
self.image = await networkManager.getHeartImageFromDatabase() // suspends for ~5s then resumes with UIImage
}
}
struct CheckedContinuationBootcamp: View {
@StateObject private var viewModel = CheckedContinuationBootcampViewModel()
var body: some View {
ZStack {
if let image = viewModel.image {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
}
}
.task {
// await viewModel.getImage() // fetches a random image from picsum using the continuation-wrapped URLSession
await viewModel.getHeartImage() // demonstrates wrapping a custom callback API; shows heart.fill after ~5 seconds
}
}
}
struct CheckedContinuationBootcamp_Previews: PreviewProvider {
static var previews: some View {
CheckedContinuationBootcamp()
}
}Code Walkthrough
getDatavsgetData2— identical results, different mechanisms —getDatausesURLSession.data(from:), which is a native async function.getData2wrapsURLSession.dataTask(with:), which is the callback-based API that predates async/await. The outputs are identical.getData2is the pattern you apply to any third-party SDK or system API that doesn't yet have an async version.The three branches in
getData2's callback —URLSession.dataTaskcan complete with data only, an error only, or (theoretically) neither. Every single branch must callcontinuation.resumeexactly once. Missing theelsebranch would leave the continuation unresumed if bothdataanderrorare somehow nil — the task would hang indefinitely. The runtime "checked" version will log a warning when this continuation is deallocated without being resumed, helping you catch this during development.URLSession.dataTask(...).resume()vscontinuation.resume(...)— These are two completely different.resume()calls.URLSession.dataTaskreturns aURLSessionDataTaskthat is created in a suspended state — you must call.resume()on it to start the network request.continuation.resume(returning:)is how you deliver the result back to the awaiting task. Forgetting theURLSessionDataTask.resume()means the network request never starts and the continuation is never resumed — the task hangs.getHeartImageFromDatabase()— the wrapping pattern — The continuation is captured by the completion handler closure. WhenDispatchQueue.main.asyncAfterfires after 5 seconds, it calls the closure, which callscontinuation.resume(returning: image). At that moment, the task that was awaitinggetHeartImageFromDatabase()resumes with the image value. Notice thatwithCheckedContinuation(notwithCheckedThrowingContinuation) is used because there is no error path — the callback always succeeds.Thread safety of continuation.resume —
continuation.resumecan be called from any thread — the main thread, a background thread, a GCD queue, a delegate callback. Swift's continuation mechanism handles the context switching. IngetHeartImageFromDatabase, the callback fires on the main queue, socontinuation.resumeis called from the main thread. The awaiting task resumes on the cooperative thread pool. This is correct and safe.
Common Mistakes
Mistake: Resuming a continuation more than oncecontinuation.resume(returning:) called twice crashes immediately with a fatal error: "SWIFT TASK CONTINUATION MISUSE: ... tried to resume its continuation more than once." This can happen when error handling paths are incomplete — for example, an if let that resumes on success, and a defer that also calls resume unconditionally. Map out every code path and ensure exactly one resume per path.
Mistake: Never resuming a continuation If the completion handler never fires (e.g., a delegate-based API where you forgot to set the delegate, or a callback that is only called on success but not on cancellation), the continuation is never resumed. The awaiting task hangs forever — this is a deadlock at the task level. The withCheckedContinuation runtime will print a warning when the continuation is deallocated without being resumed, but by then the task is already stuck. Always check that every code path through the callback calls resume.
Mistake: Wrapping a modern async API in a continuation If the API you're calling already has an async version (like URLSession.data(from:)), do not wrap it in withCheckedContinuation. The getData function in the sample is the correct approach — call the async version directly. Wrapping async APIs in continuations adds overhead and complexity with no benefit. Continuations exist for wrapping callback-based or delegate-based APIs that have no async equivalent.
Key Takeaways
withCheckedThrowingContinuationis the bridge between callback-based APIs and async/await — it suspends the task, gives you a resume handle to pass into the callback, and resumes the task when the callback fires- Every possible code path through the callback closure must call
continuation.resumeexactly once — zero resumes causes a task-level deadlock, two resumes causes a crash - Use
withCheckedContinuation(non-throwing) when the callback API cannot fail; usewithCheckedThrowingContinuationwhen the callback can deliver an error — both provide runtime checks during development that catch misuse
Last updated: June 27, 2026