How to use AsyncPublisher to convert @Published to Async / Await | Modern Swift Concurrency #12
If you have existing Combine publishers in your codebase but want to consume them using async/await without ripping out all your @Published properties, AsyncPublisher provides an exact bridge — turning any @Published value stream into an AsyncSequence you can iterate with for await.
What You'll Learn
- What
AsyncPublisheris and how it bridges Combine into Swift's structured concurrency model - How to access the
valuesproperty on any@Publishedwrapper to get anAsyncSequence - Why
for awaitover a publisher'svaluesreplaces.sinkand eliminatesAnyCancellablestorage - How task cancellation automatically tears down the
for awaitloop, preventing memory leaks - When to prefer
AsyncPublisherover Combine subscriptions and when Combine is still the right tool
Mental Model
Think of a Combine publisher as a garden hose — it pushes water (values) whenever they are available, and you need to hold a handle (the AnyCancellable) to keep the flow going. Drop the handle and the hose disconnects.
AsyncPublisher converts that hose into a drinking fountain with a sensor: you hold your hands under it (for await), it dispenses water one cup at a time whenever the source produces a new value, and the moment you walk away (task cancelled), the fountain stops on its own. You never need to carry the handle because the lifetime is tied to the task, not a stored subscription object.
The key difference: Combine is push-based (the publisher decides when you get values) while AsyncSequence is pull-based (you iterate when you are ready). AsyncPublisher converts push into pull by buffering one value at a time and suspending the consumer until the next value arrives.
Detailed Explanation
AsyncPublisher is a type in the Combine framework (available from iOS 15 / macOS 12) that wraps any Publisher and exposes its output as an AsyncSequence. Every Published property wrapper automatically provides this through its projectedValue via the $ prefix: manager.$myData gives you the Published<[String]>.Publisher, and .values on that publisher gives you the AsyncPublisher you iterate with for await.
The Combine approach to observing a @Published property requires a Set<AnyCancellable> stored on the subscriber, a .receive(on:) operator to hop to the main thread, and a .sink closure. The equivalent with AsyncPublisher is a single for await loop inside a Task. Cancellation of the outer Task automatically breaks out of the loop and releases the publisher subscription — no need to manage cancellables at all for this subscription.
This is particularly powerful in view models that mix Combine-backed data sources (a third-party SDK, a system framework, a legacy service layer) with new async/await code. Instead of maintaining two subscription systems, you can pull Combine streams into structured concurrency using .values and unify all your asynchronous work under Task.
The trade-off: AsyncPublisher drops values if the consumer is slow (back-pressure is not supported the same way as Combine's demand model), and it only works with non-throwing publishers. For failing publishers, use values on a Publisher that maps errors, or handle errors before converting. Also note that for await over .values will not exit until the publisher completes — a @Published property never completes unless you explicitly deallocate it, so cancel the Task explicitly when the subscriber goes away.
Code Structure
The sample is in AsyncPublisherBootcamp.swift and shows both the Combine approach (commented out) and the AsyncPublisher approach side by side. AsyncPublisherDataManager owns a @Published array that grows via async appends with simulated network delays. The view model bridges that publisher using for await value in manager.$myData.values inside a structured Task, and the commented-out Combine subscription shows the equivalent older pattern for comparison.
Complete Code
AsyncPublisherBootcamp.swift
import SwiftUI
import Combine
// Data layer: owns the source-of-truth array and mutates it asynchronously.
// This class is deliberately simple — it could represent a network manager or database layer.
class AsyncPublisherDataManager {
// @Published creates both a stored property and a Combine Publisher.
// Accessing `$myData` gives a Publisher<[String], Never> we can subscribe to.
@Published var myData: [String] = []
// `async` allows each append to be separated by a real suspension point (Task.sleep).
func addData() async {
myData.append("Apple")
try? await Task.sleep(nanoseconds: 2_000_000_000) // suspend for 2 seconds before next append
myData.append("Banana")
try? await Task.sleep(nanoseconds: 2_000_000_000)
myData.append("Orange")
try? await Task.sleep(nanoseconds: 2_000_000_000)
myData.append("Watermelon")
}
}
class AsyncPublisherBootcampViewModel: ObservableObject {
// @MainActor on this property ensures SwiftUI always receives updates on the main thread.
@MainActor @Published var dataArray: [String] = []
let manager = AsyncPublisherDataManager()
var cancellables = Set<AnyCancellable>() // kept for potential future Combine subscriptions
init() {
addSubscribers()
}
private func addSubscribers() {
// AsyncPublisher approach: iterate manager.$myData.values with for await.
// `manager.$myData` is the Combine publisher; `.values` converts it to AsyncSequence.
Task {
for await value in manager.$myData.values {
// We're inside a Task, so we must hop back to MainActor for UI updates.
await MainActor.run(body: {
self.dataArray = value // each new array replaces the previous one
})
}
}
// Task {
// for await value in manager.$myData.values {
// await MainActor.run(body: {
// self.dataArray = value
// })
// }
// }
// manager.$myData
// .receive(on: DispatchQueue.main, options: nil) // Combine equivalent: hop to main thread
// .sink { dataArray in
// self.dataArray = dataArray // Combine equivalent: update state in sink closure
// }
// .store(in: &cancellables) // must retain the AnyCancellable or subscription is lost
}
// Called from the view's .task modifier to drive data production.
func start() async {
await manager.addData()
}
}
struct AsyncPublisherBootcamp: View {
@StateObject private var viewModel = AsyncPublisherBootcampViewModel()
var body: some View {
ScrollView {
VStack {
// Each new item appended in the data manager eventually appears here
// because the for await loop in the view model observes every emission.
ForEach(viewModel.dataArray, id: \.self) {
Text($0)
.font(.headline)
}
}
}
// .task is tied to the view's lifetime — cancelled automatically on disappear.
.task {
await viewModel.start()
}
}
}
struct AsyncPublisherBootcamp_Previews: PreviewProvider {
static var previews: some View {
AsyncPublisherBootcamp()
}
}Code Walkthrough
@Published var myData— Every@Publishedproperty is backed by a CombineCurrentValueSubjectunder the hood. Accessing$myDataexposes thePublisherside of that subject, and appending tomyDatasends a new value through that publisher to all subscribers.manager.$myData.values— The.valuesproperty on any non-throwing Combine publisher returns anAsyncPublisher<P>, which conforms toAsyncSequence. This is the bridge: you get all the emissions from the Combine publisher, delivered one by one to afor awaitloop.for await value in manager.$myData.values— This loop suspends at each iteration, waiting for the next emission frommyData. It receives the full current array each time because@Publishedemits the entire new value, not just the delta. The loop runs forever — it never exits unless the publisher completes (which@Publishednever does) or the enclosingTaskis cancelled.await MainActor.run { self.dataArray = value }— Thefor awaitloop runs on whatever actor or thread the enclosingTaskis on. BecauseAsyncPublisherBootcampViewModelis not@MainActor, we must explicitly hop to the main actor before mutatingdataArray, which is@MainActor @Publishedand must only be written from the main thread.Commented-out Combine subscription — This is the pre-
AsyncPublisherequivalent. It requires.receive(on: DispatchQueue.main)instead ofawait MainActor.run, and it requires storing theAnyCancellableto keep the subscription alive. TheAsyncPublisherversion is fewer lines and ties lifetime to theTaskautomatically.
Common Mistakes
Mistake: Forgetting that for await over a @Published publisher loops forever.
A @Published property never sends a completion event, so for await value in $myData.values will never exit on its own. If you create the Task in an init without storing a reference to it, and without cancelling it on deinit, the task keeps the owning object alive indefinitely — a memory leak combined with stale work continuing after the view disappears. Always store the Task reference and cancel it in deinit or onDisappear.
Mistake: Updating @MainActor-isolated properties from inside a for await loop without hopping actors.
The Task created in addSubscribers() does not inherit @MainActor isolation unless the enclosing function or class is annotated. Without await MainActor.run, assigning to self.dataArray from inside the loop is a cross-actor write, which is a data race in Swift 6 strict mode and a runtime warning in Swift 5. Always confirm which actor your Task closure is running on before mutating UI state.
Mistake: Mixing a for await loop and a .sink subscription on the same publisher.
Both will receive every emission independently. You will apply your logic twice per event, doubling updates to state and potentially causing unexpected side effects. Pick one approach and remove the other. The commented-out Combine code in this sample exists only as a learning comparison — in production, keep one or the other.
Key Takeaways
AsyncPublisher(accessed via.valueson any non-throwing publisher) bridges Combine into Swift's structured concurrency model without changing your existing@Publisheddata sources.- A
for awaitloop over.valuesreplaces.sink+AnyCancellablestorage — lifetime is managed by task cancellation, not by a retained subscription handle. - The
@Publishedpublisher never completes, so its correspondingfor awaitloop runs until the task is cancelled; always cancel the task explicitly when the subscriber is deallocated.
Last updated: June 27, 2026