Swift

Checked Continuations in Swift: Bridging Callback APIs Without Leaks or Double Resumes

By Seren  |  28 Aug, 2026  |  Leave a comment


My first continuation wrapper was only six lines long, and I was rather proud of it. A callback came in, I called resume, and an awkward legacy API suddenly worked with async/await. Then a cancellation test sat waiting until the test timeout. The callback API had a branch that returned without calling its completion handler.

A week later I found the opposite problem in another wrapper: a delegate could report a cached result and a refreshed result, so the same continuation was resumed twice. Those two bugs taught me that the syntax is the easy part. The real work is understanding the old API’s lifecycle.

This article is the checklist I built from those mistakes. I still like continuations, but I no longer write one until I have drawn every terminal path on paper.

The Rule I Initially Underestimated

withCheckedContinuation and withCheckedThrowingContinuation provide a continuation value to a closure. Inside that closure, start the legacy operation and retain the continuation only as long as necessary. When a terminal result arrives, call one resume method. The suspended async function then continues with the supplied value or error.

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

This direct wrapper is correct only if the completion handler is guaranteed to run exactly once. Documentation, source inspection, and failure testing matter. Some APIs omit callbacks after cancellation, call completion more than once during retries, or use separate success and failure delegates. The wrapper must normalize those behaviors into one terminal event.

Checked continuations add diagnostics, not automatic lifecycle management. They can warn about misuse, but they cannot decide which callback should win or cancel an underlying operation for you.

The Test That Waited Forever

A missing callback can be subtle. Imagine a legacy method that returns early when input is invalid:

func fetch(_ key: String, completion: @escaping (Result<Data, Error>) -> Void) {
    guard !key.isEmpty else { return }
    // Start request...
}

An async wrapper around this API hangs forever for an empty key. The caller may keep a loading indicator visible, retain task-local resources, and wait in a task group that never completes. Fix the problem at the narrowest reliable layer. Validate the input before creating the continuation, or change the legacy API so every accepted call produces a terminal callback.

Delegates create a related risk. If the delegate object is deallocated before the system reports completion, the callback may vanish. The bridge object needs an explicit owner, and it should release its continuation after resuming. Draw the ownership chain on paper: caller task, bridge, legacy operation, delegate, and continuation. If no strong path keeps the necessary bridge alive, the function may never finish.

Then I Hit the Opposite Bug

Some APIs send cached data immediately and refreshed data later. Others report progress and completion through similar callbacks. Wrapping a multi-event API as a single async function is a modeling error unless the wrapper deliberately chooses one event.

A naive “resume if continuation is not nil” check can still race when callbacks arrive from different threads. Both threads may observe a non-nil value before either clears it. The check and removal must be one synchronized operation.

final class OneShotBox<Value>: @unchecked Sendable {
    private let lock = NSLock()
    private var continuation: CheckedContinuation<Value, Error>?

    func take() -> CheckedContinuation<Value, Error>? {
        lock.withLock {
            defer { continuation = nil }
            return continuation
        }
    }
}

This sketch illustrates atomic “take once” behavior, but a complete implementation also needs initialization, cancellation, and error handling. The synchronization invariant must cover every access. If the source naturally emits many values, use AsyncStream or AsyncThrowingStream instead of throwing information away.

Cancellation Was the Missing Wire

Cancelling the Swift task that awaits a continuation does not automatically cancel the underlying URL request, Bluetooth scan, database query, or delegate operation. The continuation may still resume later. A robust bridge defines how cancellation reaches the legacy API and how races between completion and cancellation are resolved.

withTaskCancellationHandler can connect the task’s cancellation to a cancellation method on the legacy operation. However, cancellation may occur before the operation token has been stored. That creates another state machine: not started, running, completed, or cancelled. Protect the state with one owner, such as an actor or a carefully locked bridge object.

Choose an explicit policy. You may cancel the underlying work and resume with CancellationError, or let shared work continue while only the current waiter stops caring. Neither policy is universally correct. The important point is that the decision is documented and tested.

Why One Stored Continuation Was Not Enough

A delegate-based manager may run several operations concurrently. Storing one continuation in a single property causes the newest request to overwrite the previous one. Instead, assign each operation an identifier and store a dictionary from identifier to continuation. On success or failure, remove the matching entry before resuming it.

Removal-before-resume is a useful ordering rule. Resuming can immediately schedule caller code, and that code may re-enter the bridge or begin another operation. Clearing terminal state first prevents the resumed task from observing an operation that appears to be active.

If the underlying delegate delivers callbacks on a particular queue, do not assume that this alone protects all accesses. The cancellation handler or public start method may run elsewhere. Route every mutation through the same actor, serial queue, or lock.

Sometimes a Continuation Is the Wrong Tool

A continuation represents one eventual result. An AsyncStream represents a sequence of values over time. A task group represents dynamically created child tasks. Selecting the correct abstraction makes the contract visible to callers.

Location updates, progress reports, notifications, socket messages, and repeated delegate events usually belong in a stream. A one-time authorization prompt or a single image load usually fits a continuation. If an operation can produce both an initial cached value and later updates, expose a stream or create two clearly named APIs rather than hiding the second event.

Streams also need termination handling. Use the stream continuation’s termination callback to stop observers or underlying work, and ensure the producer does not retain the consumer indefinitely.

The Fake Service I Use to Break Wrappers

Create a fake callback service whose behavior is controlled by the test. It should support success, failure, no callback, duplicate callback, delayed callback, callback from different queues, and cancellation before and after start. A deterministic fake reveals contract mistakes more reliably than waiting for a real network race.

For every wrapper, test that the async function returns the correct value, throws the original error, stops work on cancellation according to policy, and cannot resume twice. Use a timeout around the no-callback scenario so the test itself does not hang forever. Verify that bridge objects and operations deallocate after terminal events to catch retention leaks.

Checked continuations are preferable during development because their diagnostics expose misuse. Unsafe continuations may reduce checking overhead, but they should follow measured evidence, not intuition. For ordinary app-level bridges, the safety signal is usually worth keeping.

What I Check Before Merging a Wrapper

  • Verify whether the source is one-shot or multi-event.
  • List every success, failure, validation, timeout, and cancellation path.
  • Guarantee exactly one atomic terminal transition.
  • Remove stored state before resuming caller code.
  • Keep delegate bridge objects alive for the full operation.
  • Propagate cancellation intentionally; do not assume it is automatic.
  • Use an identifier per concurrent operation.
  • Prefer AsyncStream for repeated values.
  • Test duplicate callbacks and callbacks from different queues.

My wrappers became much less mysterious once I stopped seeing them as closure adapters and started seeing them as tiny state machines. Once the terminal states and ownership paths are explicit, the bridge becomes predictable, testable, and easier for the next maintainer to trust.

Documentation I Check Against

Apple documents withCheckedThrowingContinuation, task cancellation handlers, and AsyncStream. Verify delegate guarantees in the documentation for the specific framework being wrapped; continuation correctness depends on that source contract, not only on the wrapper’s syntax.

Download the Complete Runnable Project

The complete code for this article is available in its own GitHub repository: https://github.com/2252408699/swift-checked-continuations-demo.

Download it with git clone, then follow the repository README to build and run the example locally.

Seren
Seren

A developer exploring iOS, Objective-C, Swift, and other tech stacks. Here to document my learning process, pitfalls, and growing journey across new technologies.

Your email address will not be published. Required fields are marked *