Home

Combine and async/await

Bridging both directions, and deciding what to migrate

The two systems interoperate well, which means migration can be incremental rather than a rewrite. This chapter is the practical end of the position taken in the introduction.

Combine to async

Every publisher exposes .values, an AsyncSequence:

for await state in model.state.values {
    render(state)
}

For a failing publisher, the sequence throws:

do {
    for try await item in api.fetchItems().values {
        process(item)
    }
} catch {
    show(error)
}

And for a one-shot publisher, .first() reduces it to a single value:

let profile = try await api.fetchProfile().values.first(where: { _ in true })

That spelling is awkward, and it is the honest answer — Combine has no .firstValue property. Most codebases add one:

extension Publisher {
    var firstValue: Output {
        get async throws {
            for try await value in values { return value }
            throw CancellationError()
        }
    }
}

Warning

Iterating .values on an infinite publisher never returns, and the surrounding Task stays alive until cancelled. Inside a .task modifier that is correct and automatic; inside a detached Task, it is a leak. Bound it with prefix, or make sure something cancels it.

Async to Combine

There is no built-in bridge in this direction. Future plus a Task is the standard shape:

extension Future where Failure == Error {
    convenience init(operation: @escaping () async throws -> Output) {
        self.init { promise in
            Task {
                do {
                    promise(.success(try await operation()))
                } catch {
                    promise(.failure(error))
                }
            }
        }
    }
}

// usage
Future { try await api.fetchProfile() }
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { … }, receiveValue: { … })

Two caveats. Future runs eagerly on creation, so wrap it in Deferred if you want the work to start on subscription. And cancelling the resulting publisher does not cancel the Task — the async work keeps running with nobody listening. If cancellation matters, capture the task and cancel it in handleEvents(receiveCancel:).

Deferred {
    let task = Task { try await api.fetchProfile() }
    return Future { promise in
        Task { promise(await task.result) }
    }
    .handleEvents(receiveCancel: { task.cancel() })
}

At which point it is worth asking whether the Combine wrapper is earning its keep.

The comparison, honestly

Job Combine Structured concurrency
One request Future, awkward try await — clearly better
Sequence of values Publisher AsyncSequence — comparable
Debounce .debounce — one line Hand-rolled, or AsyncAlgorithms
Combine latest of 3 .combineLatest — one line Genuinely awkward
Retry with backoff Recursive catch — ugly for loop with Task.sleep — clearly better
Cancellation Manual AnyCancellable Structured, automatic
Parallel requests flatMap(maxPublishers:) withTaskGroup — clearly better
UI state observation @Published @Observable — clearly better
Error handling Terminates the stream try/catch — clearly better

The pattern in that table is the whole argument. Structured concurrency wins wherever the work has a beginning and an end, because that is what it was designed for and because cancellation and error propagation come free. Combine holds on where the work is a continuous stream of events being composed — debounce, combineLatest, throttle — and those are exactly the cases AsyncAlgorithms exists to eventually cover.

What to migrate, and in what order

Not all at once, and not by rule. The order that keeps a codebase working:

Migrate first: one-shot requests. A Future or a single-value publisher wrapping a network call becomes try await with less code, real cancellation, and an error path that does not kill anything else. This is pure profit.

Migrate second: ObservableObject to @Observable. This removes @Published, which removes the largest population of Combine publishers in most apps, and gives you finer-grained view updates as a side effect.

Leave for now: pipelines whose value is the operators. A search field built on debounce + removeDuplicates + switchToLatest is four correct lines. Rewriting it by hand is thirty lines with new bugs, for no user-visible benefit.

Leave alone: anything working, complex and untested. A four-hundred-line sync pipeline that has been correct in production for three years does not owe you a migration. Rewrite it when you need to change it, not before.

A migration that is worth doing today

The single highest-value change in most Combine codebases is replacing pipelines that terminate silently on error with either Failure == Never and state-as-values, or with async/await where try makes the failure impossible to ignore. That bug class — the feature that quietly dies after the first network error — is the framework’s most expensive failure mode, and both fixes remove it permanently.