Combine vs `AsyncSequence`: Which Should You Use in 2025?
Combine launched at WWDC 2019 as Apple’s answer to reactive programming — a declarative, operator-rich framework for
modeling asynchronous events as streams. Three years later, AsyncSequence arrived with Swift Concurrency, offering a
different way to consume asynchronous values using for await loops. Now both live in the same codebases, often solving
the same problems, and the question of which to reach for in new code has become genuinely non-trivial.
This post compares Combine and AsyncSequence side by side using a real-world use case, maps out where each framework
excels, covers the migration path from Combine to Swift Concurrency primitives, and gives you a decision table you can
use today. You’ll need familiarity with closures and
async sequences before diving in.
Apple Docs:
AsyncSequence— Swift Standard Library |Publisher— Combine
Contents
- The Problem: Live Search Debouncing
- Combine’s Strengths
- AsyncSequence’s Strengths
- The Migration Path
- Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem: Live Search Debouncing
Live search is the canonical reactive programming example: a user types into a text field, you wait for a pause in input, deduplicate identical queries, then fire a network request. The result updates as the user types. It requires debouncing, deduplication, cancellation of in-flight requests, and main-thread UI updates — every ingredient of a reactive stream.
Here is the data model both approaches will use:
import Foundation
struct PixarFilm: Identifiable, Sendable, Decodable {
let id: UUID
let title: String
let year: Int
let studio: String
}
// Simplified for clarity — real implementation would hit a URLSession endpoint
actor FilmService {
func search(query: String) async throws -> [PixarFilm] {
guard !query.isEmpty else { return [] }
try await Task.sleep(for: .milliseconds(100)) // simulate network
return PixarFilm.all.filter {
$0.title.localizedCaseInsensitiveContains(query)
}
}
}
This is the target: type “Up”, wait 300 ms after the last keystroke, call FilmService.search, update the view.
Combine’s Strengths
Combine expresses this flow as a pipeline. Each operator transforms or time-shifts the stream before the final .sink
subscriber receives values.
import Combine
import SwiftUI
@MainActor
final class FilmSearchViewModel: ObservableObject {
@Published var query = ""
@Published var films: [PixarFilm] = []
@Published var isLoading = false
private let filmService = FilmService()
private var cancellables = Set<AnyCancellable>()
init() {
$query
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.handleEvents(receiveOutput: { [weak self] _ in
self?.isLoading = true
})
.flatMap { [weak self] query -> AnyPublisher<[PixarFilm], Never> in
guard let self else { return Just([]).eraseToAnyPublisher() }
return Future { promise in
Task {
let results = try? await self.filmService.search(query: query)
promise(.success(results ?? []))
}
}
.eraseToAnyPublisher()
}
.receive(on: RunLoop.main)
.sink { [weak self] results in
self?.isLoading = false
self?.films = results
}
.store(in: &cancellables)
}
}
The wrapping of the async call inside Future is already a code smell — Combine predates Swift Concurrency and the
two don’t compose cleanly at the seams. Still, this pattern works, and it demonstrates Combine’s core strengths:
Operator richness. debounce, removeDuplicates, flatMap, handleEvents, receive(on:) — each is a single-line
transformation. The Combine operator catalog (40+) covers cases like zip, combineLatest, throttle, retry,
timeout, collect, and share that have no direct AsyncSequence equivalent in the standard library.
Multi-subscriber support. A single Combine Publisher can have multiple subscribers. Each new .sink receives
values independently, and operators like share() multicast to all subscribers.
AnyCancellable lifecycle management. Storing cancellables in a Set<AnyCancellable> ties subscription lifetime to
object lifetime. When the view model deinitializes, subscriptions cancel automatically — a well-understood, predictable
pattern.
Production hardening. Combine has been shipping since iOS 13. Its behavior under memory pressure, thread changes, and re-entrancy is documented and battle-tested. You know the edge cases because the community has hit them.
Note: The
Futurewrapper in the example above is the tell-tale sign you’re fighting the seam between Combine and Swift Concurrency. If you find yourself writingFuture { promise in Task { ... } }regularly, that’s a migration signal.
AsyncSequence’s Strengths
The same live search implemented with AsyncSequence reads as a simple loop:
import SwiftUI
@Observable
@MainActor
final class FilmSearchViewModel {
var query = ""
var films: [PixarFilm] = []
var isLoading = false
private let filmService = FilmService()
// Call this from .task { } in the SwiftUI view
func observeSearch(queries: AsyncStream<String>) async {
// swift-async-algorithms provides debounce on AsyncSequence
let debounced = queries.debounce(for: .milliseconds(300))
for await q in debounced.removeDuplicates() {
isLoading = true
do {
films = try await filmService.search(query: q)
} catch {
films = []
}
isLoading = false
}
}
}
And the SwiftUI view that drives it:
struct FilmSearchView: View {
@State private var viewModel = FilmSearchViewModel()
@State private var searchQuery = ""
var body: some View {
NavigationStack {
List(viewModel.films) { film in
VStack(alignment: .leading) {
Text(film.title).font(.headline)
Text(String(film.year)).foregroundStyle(.secondary)
}
}
.searchable(text: $searchQuery)
.overlay {
if viewModel.isLoading { ProgressView() }
}
}
.task(id: searchQuery) {
// Creates a new stream each time the query changes
let (stream, continuation) = AsyncStream.makeStream(of: String.self)
continuation.yield(searchQuery)
await viewModel.observeSearch(queries: stream)
}
}
}
Note: The
debounceandremoveDuplicatesoperators onAsyncSequenceare not in the Swift Standard Library — they require the swift-async-algorithms package, covered in Advanced Usage below.
AsyncSequence’s advantages become apparent when you look at what’s missing compared to the Combine version:
Native Swift integration. for await is a language feature, not a framework. There is no AnyCancellable, no
eraseToAnyPublisher(), no type erasure. The loop reads top-to-bottom like synchronous code.
Automatic cancellation with structured concurrency. When the .task modifier’s surrounding view disappears, SwiftUI
cancels the Task. That cancellation propagates into the for await loop, which throws a CancellationError and
exits. No Set<AnyCancellable> to manage; no .store(in: &cancellables) to remember.
No Combine bridging. The FilmService uses async throws directly. There is no Future wrapper, no Deferred, no
.values bridge.
Works with structured concurrency. async let, TaskGroup, and withTaskCancellationHandler all compose naturally
with AsyncSequence. You can zip two sequences using swift-async-algorithms and process them in a TaskGroup
without leaving the structured concurrency model.
The Migration Path
If you have a Combine-heavy codebase and want to move toward AsyncSequence, the migration happens in stages. The two
most common replacement patterns are:
Replace PassthroughSubject with AsyncStream
PassthroughSubject is the imperative bridge
into Combine — you call .send(_:) to push values.
AsyncStream fills the same role for Swift Concurrency:
// Combine
let eventSubject = PassthroughSubject<SceneEvent, Never>()
func dispatchEvent(_ event: SceneEvent) {
eventSubject.send(event)
}
eventSubject
.sink { event in handleEvent(event) }
.store(in: &cancellables)
// AsyncStream equivalent
let (eventStream, eventContinuation) = AsyncStream.makeStream(of: SceneEvent.self)
func dispatchEvent(_ event: SceneEvent) {
eventContinuation.yield(event)
}
Task {
for await event in eventStream {
handleEvent(event)
}
}
The lifecycle is different: AsyncStream has a single consumer (by default), while PassthroughSubject supports
multiple subscribers. If multi-consumer behavior is required, see Advanced Usage.
Replace @Published with @Observable
@Published was introduced alongside Combine to drive
SwiftUI updates through ObservableObject. In Swift 5.9+, the
@Observable macro replaces ObservableObject
entirely — no Combine dependency, no objectWillChange publisher, and granular per-property observation:
// Combine era: @Published + ObservableObject
class PixarWatchlistViewModel: ObservableObject {
@Published var watchlist: [PixarFilm] = []
@Published var selectedFilm: PixarFilm?
private var cancellables = Set<AnyCancellable>()
}
// Swift 5.9+ @Observable — no Combine required
@Observable
class PixarWatchlistViewModel {
var watchlist: [PixarFilm] = []
var selectedFilm: PixarFilm?
// No cancellables needed — @Observable does not use Combine
}
SwiftUI’s @State and @Environment work directly with @Observable types without needing @ObservedObject or
@StateObject.
Bridging: publisher.values
Combine publishes a .values property that returns an
AsyncPublisher — an AsyncSequence backed
by the publisher. This lets you consume existing Combine pipelines in a for await loop without rewriting them:
// Consuming a Combine publisher as an AsyncSequence
let notificationStream = NotificationCenter.default
.publisher(for: UIApplication.didBecomeActiveNotification)
.values // ← AsyncPublisher
Task {
for await _ in notificationStream {
await viewModel.refreshWatchlist()
}
}
This bridge is particularly useful when you depend on a Combine-only third-party SDK or a system API that hasn’t migrated to async/await yet.
Advanced Usage
swift-async-algorithms: Filling the Gaps
The Swift Standard Library’s AsyncSequence protocol deliberately has few built-in operators. The
swift-async-algorithms package (maintained by Apple) provides the
missing ones:
import AsyncAlgorithms
// Debounce: wait 300ms after the last value before forwarding
let debouncedQueries = searchQueries.debounce(for: .milliseconds(300))
// Throttle: pass at most one value per interval
let throttledEvents = sceneEvents.throttle(for: .seconds(1))
// Merge: combine two async sequences into one
let allEvents = merge(userEvents, systemEvents)
// Zip: pair elements from two sequences
for await (film, rating) in zip(films, userRatings) {
process(film: film, rating: rating)
}
// Chain: concatenate sequences
let allFilms = chain(pixarFilms, disneyFilms)
Add it to your Package.swift:
.package(
url: "https://github.com/apple/swift-async-algorithms",
from: "1.0.0"
)
With swift-async-algorithms, AsyncSequence has operator parity with most everyday Combine usage. The remaining gaps
are multi-subscriber support and the more exotic operators (combineLatest with three+ sources, collect(every:),
retry-with-backoff).
Multi-Consumer AsyncSequence vs. Combine’s Multi-Subscriber Publisher
This is the sharpest edge in the comparison. A Combine Publisher can have arbitrarily many subscribers — each gets
every value independently. AsyncSequence is, by default, a single-consumer protocol. Calling for await on the same
sequence from two tasks produces undefined behavior for most conformers.
If you need multi-consumer behavior with AsyncSequence, two patterns exist:
1. Broadcast with AsyncStream + AsyncChannel (from swift-async-algorithms):
import AsyncAlgorithms
// AsyncChannel supports multiple consumers
let renderChannel = AsyncChannel<RenderFrame>()
// Producer
Task {
for frame in generatedFrames {
await renderChannel.send(frame)
}
}
// Consumer 1 — preview renderer
Task {
for await frame in renderChannel {
await previewRenderer.display(frame)
}
}
// Consumer 2 — encode to disk
// ⚠️ AsyncChannel distributes — each value goes to ONE consumer
// For true broadcast, you need multiple channels or a fan-out pattern
Task {
for await frame in renderChannel {
await encoder.encode(frame)
}
}
Warning:
AsyncChanneldistributes (each value goes to one consumer in round-robin fashion), not broadcasts (each consumer receives every value). For true broadcast semantics — where every consumer receives every value — you need a fan-out structure with multipleAsyncStreamcontinuations, or you should stay with Combine’sshare()operator.
2. Keep Combine for multi-subscriber scenarios:
// Combine handles true broadcast naturally
let framePublisher = renderPipeline
.publisher
.share() // multicasts to all subscribers
framePublisher
.sink { previewRenderer.display($0) }
.store(in: &cancellables)
framePublisher
.sink { encoder.encode($0) }
.store(in: &cancellables)
This is the scenario where Combine still wins outright in 2025. If multiple independent consumers need every event,
Combine’s share() is one line. The AsyncSequence equivalent requires manual coordination.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
New SwiftUI code, @Observable view models | AsyncSequence — no Combine dependency, natural for await loops |
| UIKit codebase, existing Combine pipelines | Keep Combine — migrating working code has no technical payoff |
Complex operator chains (combineLatest, zip 3+ sources) | Combine — operator catalog is more complete |
| Simple sequential data flows | AsyncSequence — for await is easier to read and debug |
| Multi-subscriber broadcast (every consumer gets every value) | Combine with share() — AsyncSequence has no direct equivalent |
| Imperatively pushing values (event buses, user actions) | AsyncStream replacing PassthroughSubject in new code |
| Consuming async/await APIs | AsyncSequence — no Future bridging required |
| Third-party SDK returns a Combine publisher | Use .values to bridge into AsyncSequence at the call site |
| Need retry-with-backoff, timeout, or collect operators | Combine for now, or implement via swift-async-algorithms + manual logic |
SwiftUI .searchable + debounced network calls | AsyncSequence + swift-async-algorithms debounce |
Summary
- Combine excels at complex operator chains, multi-subscriber broadcast, and UIKit codebases with years of existing
pipeline investment. Its 40+ operators cover reactive patterns that
AsyncSequencedoesn’t handle natively. AsyncSequenceexcels in new Swift Concurrency code — it reads like synchronous code, integrates naturally withasync/await, and cancels automatically through structured concurrency. NoAnyCancellable, no@Published, no type erasure boilerplate.PassthroughSubject→AsyncStreamand@Published→@Observableare the two highest-value migration steps for a Combine-heavy codebase moving to Swift Concurrency.swift-async-algorithmscloses most of the operator gap —debounce,throttle,merge,zip,chain— and is maintained by Apple.- Multi-subscriber broadcast is Combine’s most defensible remaining advantage.
AsyncChanneldistributes;share()broadcasts. If your design requires the latter, keep Combine. - The
.valuesproperty on anyPublisherlets you bridge Combine intofor awaitloops without rewriting the publisher side — a pragmatic incremental approach.
The AsyncSequence story continues to improve: as swift-async-algorithms matures and more Apple APIs adopt
async/await natively, the remaining Combine-only use cases will shrink. For a deeper look at how @Observable replaces
ObservableObject across your entire view layer, see The Observation Framework.