Swift Continuations: withUnsafeContinuation, withCheckedContinuation and more!

Hey Swifters :), In this guide i would like to talk about continuations in Swift and their types. Let’s say you are working on your iOS app, and you need to use an old networking library that still uses completion handlers. But your shiny new Swift code is all async/await. It’s like trying to connect a vintage radio to a modern Bluetooth speaker. This is where Swift continuations come to the rescue. Think of them as translators that help your old callback based code talk to your new async/await code. Let’s start with

What Are Continuations Anyway?

Before we jump into the different types, let’s understand what a continuation actually is. Imagine you are reading a book and someone interrupts you, you put a bookmark on the page so you can continue reading later. A continuation in Swift is basically that bookmark, it remembers where your async function left off so it can resume later when the callback fires. Swift gives us four different continuation functions, each with its own personality:

  1. withUnsafeContinuation
  2. withCheckedContinuation
  3. withUnsafeThrowingContinuation
  4. withCheckedThrowingContinuation

Lets explore them one by one.

1. withUnsafeContinuation:

This is the Formula 1 car of continuations, fast but dangerous if you don’t know what you’re doing. Letss say you have an old school timer function that uses callbacks:

Swift
func oldSchoolTimer(seconds: Int, completion: @escaping () -> Void) {
    DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(seconds)) {
        completion()
    }
}

Before (Callback Hell):

Swift
func doSomethingWithTimer() {
    oldSchoolTimer(seconds: 2) {
        print("Timer finished!")
        // Now what if you need another timer?
        oldSchoolTimer(seconds: 1) {
            print("Second timer finished!")
            // And another? This gets messy fast...
        }
    }
}

After (Async Paradise):

Swift
func modernTimer(seconds: Int) async {
    await withUnsafeContinuation { continuation in
        oldSchoolTimer(seconds: seconds) {
            continuation.resume() // This is our "bookmark" being used
        }
    }
}

func doSomethingWithTimer() async {
    await modernTimer(seconds: 2)
    print("Timer finished!")
    await modernTimer(seconds: 1)
    print("Second timer finished!")
    // Much cleaner, right?
}

Why “Unsafe”? It’s called “unsafe” because Swift won’t check if you’re using the continuation correctly. If you call continuation.resume() twice, or never call it at all, your app might crash or hang forever. The key rules for Unsafe Continuations are, call resume() exactly once, call it from any thread (it’s thread-safe) and don’t store the continuation and use it later.

2. withCheckedContinuation:

This is the same as withUnsafeContinuation, but with training wheels. Swift keeps an eye on you and will crash your app with a helpful error message if you mess up during development.

Swift
func safeModernTimer(seconds: Int) async {
    await withCheckedContinuation { continuation in
        oldSchoolTimer(seconds: seconds) {
            continuation.resume()
            // If you accidentally call resume() again here,
            // Swift will catch it in debug mode!
        }
    }
}

// if called twice
// Error: _Concurrency/CheckedContinuation.swift:169: Fatal error: SWIFT TASK CONTINUATION MISUSE: safeModernTimer(seconds:) tried to resume its continuation more than once, returning ()!

Why Use Checked? Because it catches mistakes during development, is slightly slower than unsafe (but negligible) and is perfect for learning and debugging. When to Use Which? well use withCheckedContinuation while developing and testing and switch to withUnsafeContinuation for production if you need every bit of performance.

3. withUnsafeThrowingContinuation:

Now things get interesting. What if your callback based function can fail? This continuation can handle errors too. Lets say you have a network request function that can fail

Swift
func fetchUserData(userID: String, completion: @escaping (Result<User, Error>) -> Void) {
    // Simulating a network request
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        if userID == "invalid" {
            completion(.failure(NetworkError.invalidUserID))
        } else {
            completion(.success(User(name: "John Doe")))
        }
    }
}

enum NetworkError: Error {
    case invalidUserID
}

struct User {
    let name: String
}

Before (Callback with Result):

Swift
func getUserInfo() {
    fetchUserData(userID: "123") { result in
        switch result {
        case .success(let user):
            print("Got user: \(user.name)")
        case .failure(let error):
            print("Error: \(error)")
        }
    }
}

After (Async with Throws):

Swift
func modernFetchUser(userID: String) async throws -> User {
    return try await withUnsafeThrowingContinuation { continuation in
        fetchUserData(userID: userID) { result in
            switch result {
            case .success(let user):
                continuation.resume(returning: user)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

func getUserInfo() async {
    do {
        let user = try await modernFetchUser(userID: "123")
        print("Got user: \(user.name)")
    } catch {
        print("Error: \(error)")
    }
}

With this you get clean error handling with do-catch, no more nested Result handling and errors bubble up naturally through the async chain.

4. withCheckedThrowingContinuation:

This is the safety conscious version of the throwing continuation. Its like withUnsafeThrowingContinuation but with safety checks enabled.

Swift
func safeModernFetchUser(userID: String) async throws -> User {
    return try await withCheckedThrowingContinuation { continuation in
        fetchUserData(userID: userID) { result in
            switch result {
            case .success(let user):
                continuation.resume(returning: user)
            case .failure(let error):
                continuation.resume(throwing: error)
                // Swift will catch it if you try to resume again!
            }
        }
    }
}

What about the Isolation Parameter:

You might have noticed withUnsafeThrowingContinuation(isolation:_:) in the topic. The isolation parameter is a newer addition that helps with actor isolation. Here is what it means:

Swift
@MainActor
class ViewController {
    let label: UILabel = {
        let label = UILabel()
        return label
    }()

    func updateUI() async {
        let data = try? await withUnsafeThrowingContinuation(isolation: MainActor.shared) { continuation in
            // This closure runs isolated to the MainActor
            fetchUserData(userID: "user_id_here") { result in
                continuation.resume(returning: result)
            }
        }
        // We're guaranteed to be back on MainActor here
        self.label.text = "Some text"
    }
}

Arlight, so that was all about continuations in Swift. I hope you enjoyed this one and i will see you in the next one. Happy coding. 🙂


Posted

in