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

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 Future wrapper in the example above is the tell-tale sign you’re fighting the seam between Combine and Swift Concurrency. If you find yourself writing Future { 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 debounce and removeDuplicates operators on AsyncSequence are 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: AsyncChannel distributes (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 multiple AsyncStream continuations, or you should stay with Combine’s share() 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)

ScenarioRecommendation
New SwiftUI code, @Observable view modelsAsyncSequence — no Combine dependency, natural for await loops
UIKit codebase, existing Combine pipelinesKeep Combine — migrating working code has no technical payoff
Complex operator chains (combineLatest, zip 3+ sources)Combine — operator catalog is more complete
Simple sequential data flowsAsyncSequencefor 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 APIsAsyncSequence — no Future bridging required
Third-party SDK returns a Combine publisherUse .values to bridge into AsyncSequence at the call site
Need retry-with-backoff, timeout, or collect operatorsCombine for now, or implement via swift-async-algorithms + manual logic
SwiftUI .searchable + debounced network callsAsyncSequence + 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 AsyncSequence doesn’t handle natively.
  • AsyncSequence excels in new Swift Concurrency code — it reads like synchronous code, integrates naturally with async/await, and cancels automatically through structured concurrency. No AnyCancellable, no @Published, no type erasure boilerplate.
  • PassthroughSubjectAsyncStream and @Published@Observable are the two highest-value migration steps for a Combine-heavy codebase moving to Swift Concurrency.
  • swift-async-algorithms closes 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. AsyncChannel distributes; share() broadcasts. If your design requires the latter, keep Combine.
  • The .values property on any Publisher lets you bridge Combine into for await loops 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.