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.