Combine Framework in Swift 6: Publishers, Operators, and async/await Migration for iOS 26

Master the Combine framework in Swift 6, then migrate the pieces that make sense to async/await and AsyncSequence for cleaner iOS 26 code.

Combine to async/await in Swift 6 (2026)

Updated: September 9, 2026

The Combine framework in Swift 6 is Apple's reactive stream library for composing asynchronous event pipelines using Publisher, Subscriber, and operator chains. In iOS 26 it still ships as a first-party framework alongside (not replaced by) Swift's async/await, AsyncSequence, and Observation. Combine remains the right tool for hot event streams (UI, notifications, KVO), while cold, task-bound work has largely moved to structured concurrency. This guide walks through every core operator, real production patterns, and how to migrate cleanly to async/await without breaking what already works.

  • Combine is not deprecated in iOS 26. Apple actively uses it inside NotificationCenter, URLSession, and UserDefaults, and it interoperates with async/await via .values.
  • Use Combine for multicast hot streams (UI events, KVO, timers) and async/await for task-bound cold work (network calls, database reads).
  • Every Publisher exposes an .values AsyncSequence, which is the primary migration bridge introduced in iOS 15 and refined in iOS 26.
  • Under Swift 6 strict concurrency, most Combine types are Sendable, but closures passed to operators are @Sendable-checked. Capture only immutable state.
  • Backpressure in Combine uses Demand; in AsyncSequence it's controlled by the consumer's for await loop pace. Prefer async when the consumer sets the tempo.
  • @Published and ObservableObject still work fine, but new SwiftUI code should adopt @Observable from the Observation framework instead.

Is Combine deprecated in iOS 26?

No. Combine isn't deprecated in iOS 26, and Apple hasn't signaled any deprecation timeline. It ships as a system framework, all its symbols are marked @available(iOS 13, *) with no deprecated attribute in the Swift 6 SDK, and Apple's own frameworks (Foundation's NotificationCenter, URLSession.DataTaskPublisher, UserDefaults, KVO, and even Timer.publish) still surface data through Combine publishers.

What has changed is emphasis. Since iOS 15, most new asynchronous APIs Apple ships expose an async variant first and only rarely a Combine one. Apple's Combine documentation now steers you toward async/await for one-shot work, and toward AsyncSequence for streams, while keeping Combine as a stable, mature choice for existing pipelines. So the practical rule for 2026: keep Combine where it already works, adopt async for new task-bound code, and use .values to bridge when a Combine publisher meets an async caller.

Honestly, migrating everything to async purely for fashion is a bad trade. The two models have different semantics around backpressure, multicasting, and error propagation. I've made that mistake on a fintech app early in 2024, tore out perfectly good combineLatest chains for form validation, and ended up rewriting them a month later with worse ergonomics. This guide treats them as complementary, not competing.

Publishers, Subscribers, and the Combine pipeline

A Combine pipeline has three roles: a Publisher emits values and an optional failure, one or more Operators transform them, and a Subscriber receives them. The pipeline is inert until you attach a subscriber (with .sink, .assign, or a custom Subscriber), at which point the graph actually runs.

import Combine
import Foundation

// A cold publisher: fires only when subscribed.
let numbers = [1, 2, 3, 4, 5].publisher

let cancellable = numbers
    .map { $0 * $0 }                       // 1, 4, 9, 16, 25
    .filter { $0 > 5 }                     // 9, 16, 25
    .sink { value in
        print("received:", value)
    }
// keep `cancellable` alive; on deinit the subscription tears down.

Two invariants matter here. First, Publisher is generic over Output and Failure: Publisher<Int, Never> can never fail, so its sink only needs a value handler. A Publisher<Data, URLError> requires the two-closure sink(receiveCompletion:receiveValue:). The compiler enforces this, so you can't subscribe to a fallible publisher with a single-arg sink.

Second, subscriptions are reference-counted. The returned AnyCancellable tears down the whole chain when it deinits, which is why real code stores cancellables in a Set<AnyCancellable> owned by the view model. Forget this once, and your subscription silently vanishes on the next runloop tick. (I've spent an embarrassing evening chasing exactly this bug: a login screen that "worked once" then never fired again.)

Subjects: PassthroughSubject, CurrentValueSubject, and @Published

A Subject is a publisher you can send values into imperatively. Combine ships two: PassthroughSubject (no initial value, no replay) and CurrentValueSubject (holds the latest value, replays it to new subscribers). Together with the @Published property wrapper, subjects are how you turn ordinary mutable state into a stream.

import Combine

final class SearchViewModel: ObservableObject {
    // @Published wraps the property with a CurrentValueSubject internally.
    @Published var query: String = ""
    @Published private(set) var results: [String] = []

    private let events = PassthroughSubject<Event, Never>()
    private var bag = Set<AnyCancellable>()

    enum Event { case tapped, cleared }

    init() {
        $query
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .map { q in Array(repeating: q, count: 3) }  // pretend network
            .assign(to: &$results)
    }

    func send(_ e: Event) { events.send(e) }
}

Rule of thumb: reach for CurrentValueSubject (or @Published) when new subscribers should immediately see the latest state, think "selected tab" or "current user". Reach for PassthroughSubject for one-shot events with no meaningful "last value", think "user tapped save" or "deep link received". Mixing these up is one of the most common Combine bugs I've seen in code review: a login screen that briefly flashes the previous user's data because a PassthroughSubject was used where a CurrentValueSubject was needed.

Under Swift 6, subjects are Sendable, but the closures you pass to .sink must not capture non-Sendable state without isolation. If you need to update SwiftUI from a subscription, hop to the main actor explicitly with .receive(on: DispatchQueue.main), or @MainActor-annotate the receiving type.

Core operators every Combine developer must know

Combine ships over 100 operators, but roughly a dozen do 90% of real work. Learn these first. The rest are variations you'll pick up as you need them.

CategoryOperatorWhat it doesasync equivalent
TransformmapSynchronous 1:1 transformationfor await x in seq { let y = f(x) }
TransformflatMapMerge inner publishersnested for await + TaskGroup
Filterfilter, compactMap, removeDuplicatesDrop or unwrap elementsAsyncSequence.filter
CombinecombineLatest, zip, mergeCross-stream compositionAsyncAlgorithms.combineLatest
Timingdebounce, throttle, delayTiming controlAsyncAlgorithms.debounce
Errorcatch, retry, replaceErrorRecoverydo / catch + retry loop
Sharingshare, multicastOne upstream, many subscribersmanual AsyncStream + Task
Threadingreceive(on:), subscribe(on:)Scheduler control@MainActor, actors

Timing operators: debounce vs throttle

Both cut down emission rate; the difference is which value survives. debounce emits the latest value after a quiet period, which is ideal for search input where you want the final query. throttle emits the first (or the latest, depending on latest:) value per interval, which is ideal for scroll updates or rate-limited buttons where you want responsive feedback.

// Debounce: waits for silence.
searchTextPublisher
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
    .sink { query in performSearch(query) }
    .store(in: &bag)

// Throttle: hard rate cap.
scrollOffset
    .throttle(for: .milliseconds(16), scheduler: DispatchQueue.main, latest: true)
    .sink { offset in updateHeader(offset) }
    .store(in: &bag)

Composition: combineLatest, zip, and merge

combineLatest emits every time any upstream fires, with the latest tuple of values. Perfect for "submit button enabled when email AND password valid". zip pairs values in lockstep, which is perfect for merging two parallel network calls where each pair belongs together. merge flattens same-type publishers into one, perfect for unifying keyboard-show and keyboard-hide notifications.

Combine vs async/await: when to use each

Combine and async/await solve overlapping but distinct problems. Combine is a reactive stream library, best for hot, multicast, event-driven data. async/await is structured concurrency, best for scoped, cancellable, task-bound work. Neither replaces the other in iOS 26. According to Swift.org's official concurrency documentation, structured concurrency is the recommended model for new task-bound code, though Apple's own frameworks keep exposing Combine publishers where multicast semantics are the norm.

ConcernCombineasync/await + AsyncSequence
Single-shot network callWorks, verboseIdiomatic (try await URLSession.data)
UI text field → debounced searchOne-liner with .debounceNeeds AsyncAlgorithms package
Multicast to many subscribersNative (.share)Manual (AsyncStream fan-out)
Cancellation propagationManual via AnyCancellableAutomatic with Task tree
BackpressureDemand-basedConsumer pace via for await
Error typingTyped Failure genericUntyped throws (typed throws in Swift 6)
TestingTestScheduler, virtual timeClock abstraction + Task.sleep

If you're already using Combine successfully, don't rewrite. If you're starting fresh and the pipeline is short (fetch, decode, deliver), reach for async/await. Save Combine for cases where multiple views subscribe to the same live stream. That's still where it shines.

How to migrate Combine code to async/await

Migration is incremental. The bridge is the .values property that every publisher exposes: it returns an AsyncPublisher, which conforms to AsyncSequence. That means any Combine chain can be consumed by a for await loop without rewriting the upstream.

// Before: Combine chain drives a view.
final class OldFeed: ObservableObject {
    @Published var items: [Item] = []
    private var bag = Set<AnyCancellable>()

    func load() {
        URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: [Item].self, decoder: JSONDecoder())
            .replaceError(with: [])
            .receive(on: DispatchQueue.main)
            .assign(to: &$items)
    }
}

// After: async/await with @Observable (iOS 17+).
import Observation

@Observable
final class Feed {
    var items: [Item] = []

    func load() async {
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let decoded = try JSONDecoder().decode([Item].self, from: data)
            await MainActor.run { self.items = decoded }
        } catch {
            items = []
        }
    }
}

For streams that are unavoidably Combine, say a NotificationCenter publisher, bridge with .values:

func observeKeyboard() async {
    let stream = NotificationCenter.default
        .publisher(for: UIResponder.keyboardWillShowNotification)
        .values

    for await note in stream {
        // handle note; Task cancellation ends the loop cleanly.
    }
}

The migration checklist that works in practice: (1) inventory every Combine chain, (2) classify each as task-bound (fetch, save, one-shot) or event-driven (subject, notification, KVO), (3) rewrite task-bound chains to async, (4) keep event-driven chains in Combine but consume via .values, (5) drop @Published in favor of @Observable once no external subscribers remain. Our Swift TaskGroup and AsyncStream guide covers the concurrency side in depth.

Combine under Swift 6 strict concurrency

Swift 6's strict concurrency mode surfaces problems that were silent in Swift 5. Most Combine types are now correctly annotated Sendable, but your operator closures are checked as @Sendable, meaning they can't capture non-Sendable reference types without isolation.

final class Cache { var lookup: [String: Data] = [:] }  // not Sendable

// Swift 6 error: capture of 'cache' with non-Sendable type
let bad = urlPublisher
    .map { url in cache.lookup[url.absoluteString] }
    .sink { _ in }

// Fix: move mutable state onto an actor.
actor Cache {
    var lookup: [String: Data] = [:]
    func data(for url: URL) -> Data? { lookup[url.absoluteString] }
}

let good = urlPublisher
    .flatMap { url in
        Future { promise in
            Task { promise(.success(await cache.data(for: url))) }
        }
    }
    .sink { _ in }

Combine's Failure generic already gave you typed errors before Swift 6 shipped typed throws. If you're using both, converting a Combine chain's Failure into a throws(MyError) async function is straightforward. See the typed throws guide for how the two error models line up. For a deeper look at Swift 6's concurrency model overall, our Swift 6.2 approachable concurrency article covers SendingResult, isolation regions, and nonisolated(unsafe) escape hatches.

Combine, ObservableObject, and the new @Observable macro

SwiftUI's original state model (ObservableObject + @Published + @ObservedObject) is built on Combine. Every @Published property publishes changes through a PassthroughSubject that SwiftUI subscribes to. It works, but every property change invalidates the entire view, causing over-rendering.

The @Observable macro introduced with the Observation framework replaces this with per-property tracking, and it does not use Combine internally. New code should prefer @Observable; existing ObservableObject code is fine to leave alone. If you're mixing both, our Observable macro complete guide walks through the migration.

// Old world: Combine-backed.
final class OldModel: ObservableObject {
    @Published var name = ""
    @Published var email = ""
}

// New world: Observation-backed, per-property tracking.
import Observation

@Observable
final class NewModel {
    var name = ""
    var email = ""
}

The upside: SwiftUI's dependency tracker now knows that a view reading only name shouldn't re-render when email changes. In my last project, real-world view diff work dropped noticeably in complex forms and lists after we made this swap.

Debugging Combine chains in Xcode 26

Combine chains are notoriously hard to debug because errors and cancellations propagate silently. Three tools help: the .print() operator, breakpoints on handleEvents, and the Combine Inspector added in Xcode 26.

urlPublisher
    .print("URLs")                                   // logs every event
    .handleEvents(
        receiveSubscription: { _ in log("subscribed") },
        receiveOutput:       { v in log("got \(v)") },
        receiveCompletion:   { c in log("done \(c)") },
        receiveCancel:       {      log("cancelled") }
    )
    .sink { _ in }
    .store(in: &bag)

The Combine Inspector in Xcode 26 (Debug ▸ Combine Inspector) visualizes the whole subscription graph at runtime. You can see which subjects have subscribers, where demand is stalled, and which operator dropped the last value. Combined with Xcode Instruments profiling, it makes previously invisible reactive bugs tractable.

Common Combine pitfalls in iOS 26

Even seasoned developers hit these. Watch for them during code review.

  • Forgetting to store the cancellable. .sink { ... } without .store(in: &bag) gets deallocated at the end of the enclosing scope. The pipeline runs "once, maybe" and then dies silently.
  • Assuming receive(on:) is free. Each hop schedules onto a runloop and adds latency. In tight loops (scroll updates), one receive(on: DispatchQueue.main) at the tail is enough.
  • Confusing share with multicast. share is a convenience that uses a PassthroughSubject, so late subscribers miss earlier values. Use multicast(subject:) with a CurrentValueSubject if new subscribers must see the last value.
  • Combining DispatchQueue.main with SwiftUI's implicit main-actor. Under Swift 6, .receive(on: DispatchQueue.main) doesn't satisfy @MainActor requirements. Use .receive(on: RunLoop.main) or move the receiver into a @MainActor type.
  • Using Just in a hot loop. Just allocates a subscription every subscribe. In tight code paths, cache the publisher or emit through a shared subject.
  • Missing typed errors after mapping. .map preserves Failure, but .tryMap upgrades Failure to Error. Prefer mapError to keep your error type precise.

Frequently Asked Questions

Is Combine still used in 2026?

Yes. Combine is a live, supported framework in iOS 26 with no deprecation warnings on any symbols, and Apple frameworks like NotificationCenter, URLSession, and Timer still expose Combine publishers. New code often prefers async/await for one-shot work, but Combine remains the pragmatic choice for hot, multicast event streams.

What is the difference between Combine and async/await?

Combine models reactive streams (a publisher pushes values to any number of subscribers over time), while async/await models structured concurrency (a task pulls values in sequence). Combine excels at multicast events and complex operator chains; async/await excels at scoped, cancellable, task-bound work with automatic parent-child cancellation.

How do I convert a Combine publisher to async/await?

Use the .values property on any publisher: it returns an AsyncPublisher that conforms to AsyncSequence, so you can consume it with for try await value in publisher.values { }. Cancellation of the enclosing Task tears down the subscription automatically.

Should I use @Published or @Observable in iOS 26?

For new SwiftUI code, prefer @Observable from the Observation framework. It does per-property change tracking, so views only re-render when properties they actually read change. Keep @Published only when you need to expose the change stream as a Combine publisher for external subscribers.

Does Combine work with Swift 6 strict concurrency?

Yes, but with tighter closure checking. Combine's own types are Sendable in Swift 6, and operator closures are checked as @Sendable. That means you can't capture non-Sendable mutable state. Move it onto an actor or copy immutable snapshots into the closure.

When should I use PassthroughSubject vs CurrentValueSubject?

Use CurrentValueSubject when a new subscriber must immediately see the latest value: selection state, current user, feature flags. Use PassthroughSubject for one-shot events with no meaningful "last value": user taps, notifications received, navigation events. Getting this wrong is one of the most common Combine bugs.

Editorial Team
About the Author Editorial Team

Our team of expert writers and editors.