Continuation vs CheckedContinuation vs UnsafeContinuation in Swift


Greetings, traveler!

Continuations are one of those Swift Concurrency APIs that most app developers do not touch every day, but they become important as soon as old callback-based code has to be wrapped into async/await.

A typical example looks like this:

func loadUser() async throws -> User {
    try await withCheckedThrowingContinuation { continuation in
        userService.loadUser { result in
            continuation.resume(with: result)
        }
    }
}

The async function suspends, the old API continues working with callbacks, and the continuation resumes the suspended task when the result arrives.

But there is a strict rule here: a continuation must be resumed exactly once.

If it is resumed twice, the code is wrong. If it is never resumed, the task waits forever. This is not a small implementation detail. This is the whole contract of the API.

Before Swift 6.4, Swift had two main continuation types:

UnsafeContinuation
CheckedContinuation

UnsafeContinuation is fast, but it does not protect you. If you break the contract, you are on your own.

CheckedContinuation is safer. It adds runtime checks and helps catch incorrect usage, but that safety has a cost. The checked version needs additional bookkeeping, including allocation and atomic operations.

Swift 6.4 adds a third option.

The new Continuation type

Swift 6.4 introduces a new type:

Continuation<Success, Failure>

The important part is that this continuation is noncopyable. In Swift terms, it is a ~Copyable type. That means you cannot freely copy it and pass the same continuation to multiple places as if it were an ordinary value.

This matches the actual meaning of a continuation much better. A continuation is not supposed to be reused. It is a one-time resource. It exists to resume one suspended task once.

The new API makes that idea visible in the type system.

Why noncopyable?

With CheckedContinuation, this kind of mistake is possible at compile time:

func complete(_ continuation: CheckedContinuation<String, Never>) {
    continuation.resume(returning: "Done")
    continuation.resume(returning: "Done")
}

The compiler allows the code. The error is caught at runtime.

With the new Continuation, resume consumes the continuation:

func complete(_ continuation: consuming Continuation<String, Never>) {
    continuation.resume(returning: "Done")

    // ❌ Error: continuation was already consumed
    continuation.resume(returning: "Done")
}

The compiler can now reject many cases where the same continuation is used after it has already been resumed. Instead of relying only on runtime checks, Swift can use ownership rules to prevent some continuation bugs before the code runs.

This does not make every continuation bug impossible. But it moves an important class of mistakes from runtime to compile time.

The new withContinuation API

Swift 6.4 also adds a new API for creating these continuations:

let value = await withContinuation(of: String.self) { continuation in
    continuation.resume(returning: "OK")
}

For throwing code, the same API can be used with a failure type:

enum NetworkError: Error {
    case offline
}

let value = try await withContinuation(
    of: String.self,
    throwing: NetworkError.self
) { continuation in
    continuation.resume(throwing: .offline)
}

This is different from the older split between:

withCheckedContinuation
withCheckedThrowingContinuation

The new API can express both non-throwing and throwing continuations through the Failure type.

For non-throwing code, Failure is Never.

For throwing code, Failure can be a concrete error type, such as NetworkError, or a general error type, such as any Error.

This fits nicely with typed throws, because the continuation can now carry the same error type as the async function.

Why the result type is passed explicitly

The new API asks for the success type at the call site:

await withContinuation(of: Data.self) { continuation in
    ...
}

At first glance, this may look a bit verbose. But it makes the type easier to read and often helps the compiler infer the continuation type inside the closure.

It is similar to APIs like withTaskGroup(of:), where the result type is part of the call.

The old continuation APIs sometimes require extra type annotations inside the closure. The new shape keeps the important type information near the call itself.

What happens if resume is never called

The new Continuation can prevent many double-resume mistakes through ownership rules.

A missing resume is harder. For example, this is still wrong:

await withContinuation(of: String.self) { continuation in
    // The continuation is never resumed.
}

Swift cannot always prove at compile time that every possible path resumes the continuation.

So the new type uses a runtime trap when a continuation is destroyed without being resumed. That is still much better than silently leaving a task suspended forever.

A missing resume is still a bug. The difference is that the bug becomes visible.

Why CheckedContinuation still matters

The new Continuation is stricter, but it is not a drop-in replacement for every old continuation use case.

Many callback-based APIs do not have one simple callback. They have separate callbacks for success and failure:

legacyAPI.onSuccess {
    continuation.resume(returning: $0)
}

legacyAPI.onFailure {
    continuation.resume(throwing: $0)
}

But with a noncopyable continuation, this becomes complicated. The same continuation would need to be captured by more than one escaping closure. The compiler cannot prove that only one of those closures will run.

A third-party library may guarantee that success and failure are mutually exclusive. The compiler usually cannot see that guarantee.

This is where CheckedContinuation is still useful. It works well for dynamic callback patterns where the exact control flow is known only at runtime.

So the new Continuation does not make the older APIs obsolete. It gives us a stricter tool for cases where ownership is clear.

When the new API is a good fit

The new Continuation is a good fit when there is one obvious owner and one obvious resume path.

For example:

func requestToken() async -> String {
    await withContinuation(of: String.self) { continuation in
        tokenProvider.requestToken { token in
            continuation.resume(returning: token)
        }
    }
}

This kind of code maps well to the new model. The continuation is created, passed into one callback, and resumed once.

It is also useful in infrastructure code, custom async primitives, lower-level concurrency utilities, and libraries where performance matters more than in ordinary app-layer code.

The API is less convenient when the continuation needs to be stored, shared, passed into multiple callbacks, or resumed from several possible places.

When to keep using CheckedContinuation

For most app code that wraps delegate-based APIs, old SDKs, or third-party libraries, CheckedContinuation may still be the better default.

Use it when the flow looks like this:

func loadImage() async throws -> Image {
    try await withCheckedThrowingContinuation { continuation in
        imageLoader.onSuccess = { image in
            continuation.resume(returning: image)
        }

        imageLoader.onFailure = { error in
            continuation.resume(throwing: error)
        }
    }
}

This is not a bad use of CheckedContinuation. It is exactly the kind of situation where runtime checking is useful.

The compiler cannot easily prove that only one callback will fire. But CheckedContinuation can still catch broken behavior at runtime.

In short:

// Clear single ownership
// One resume path
// Performance-sensitive code
Continuation

// Multiple callbacks
// Delegate-based APIs
// Third-party guarantees
// App-level bridging code
CheckedContinuation

What about UnsafeContinuation

UnsafeContinuation still exists too.

Its usage looks very similar:

func loadUserFast() async -> User {
    await withUnsafeContinuation { continuation in
        userService.loadUser { user in
            continuation.resume(returning: user)
        }
    }
}

The difference is not in the shape of the API, but in the guarantees. UnsafeContinuation does not add the runtime checks of CheckedContinuation, and it does not use the ownership-based protection of the new Continuation.

It should be used only when you really need the lower-level behavior and you control the full flow yourself.

For most app code, UnsafeContinuation is too easy to misuse. If something goes wrong, Swift will not help much.

The new API makes the choice more interesting:

// Fast, but unsafe.
UnsafeContinuation

// Safer, runtime checked, good for dynamic callback bridging.
CheckedContinuation

// Noncopyable, stricter, faster path, better when ownership is clear.
Continuation

Final thoughts

Swift now has three continuation APIs, and the choice is mostly about how much safety the compiler or runtime can give you.

UnsafeContinuation is the lowest-level option. It is fast, but Swift will not protect you much if the continuation is resumed incorrectly. It is a tool for code where you fully control the execution path and understand the risks.

CheckedContinuation is still the best default for most app-level bridging code. It fits UIKit delegates, completion handlers, old SDKs, and third-party libraries where success, failure, cancellation, or timeout may come from different callbacks. The compiler may not understand that only one callback will fire, but runtime checks can still catch broken behavior.

The new Continuation in Swift 6.4 is stricter. It works best when ownership is simple: one continuation, one clear resume path, one suspended task. In those cases, Swift can use noncopyable semantics to prevent some mistakes before the code runs.

Use CheckedContinuation when the control flow is dynamic. Use Continuation when ownership is clear. Reach for UnsafeContinuation only when you really need it.

That is the interesting part of this change. Swift is not only adding another continuation type. It is making the “resume exactly once” rule more visible in the language itself.