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 .valuesAsyncSequence, 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.
Category
Operator
What it does
async equivalent
Transform
map
Synchronous 1:1 transformation
for await x in seq { let y = f(x) }
Transform
flatMap
Merge inner publishers
nested for await + TaskGroup
Filter
filter, compactMap, removeDuplicates
Drop or unwrap elements
AsyncSequence.filter
Combine
combineLatest, zip, merge
Cross-stream composition
AsyncAlgorithms.combineLatest
Timing
debounce, throttle, delay
Timing control
AsyncAlgorithms.debounce
Error
catch, retry, replaceError
Recovery
do / catch + retry loop
Sharing
share, multicast
One upstream, many subscribers
manual AsyncStream + Task
Threading
receive(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.
Concern
Combine
async/await + AsyncSequence
Single-shot network call
Works, verbose
Idiomatic (try await URLSession.data)
UI text field → debounced search
One-liner with .debounce
Needs AsyncAlgorithms package
Multicast to many subscribers
Native (.share)
Manual (AsyncStream fan-out)
Cancellation propagation
Manual via AnyCancellable
Automatic with Task tree
Backpressure
Demand-based
Consumer pace via for await
Error typing
Typed Failure generic
Untyped throws (typed throws in Swift 6)
Testing
TestScheduler, virtual time
Clock 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.
SwiftUI TextRenderer in iOS 26 gives you glyph-, run-, and line-level control over Text drawing. Learn the protocol, three working effects, and the Dynamic Type edges that trip most people up.
A practical guide to Apple's unified logging in Swift for iOS 26: how Logger replaces print, when to use each log level, how privacy annotations protect PII, and how OSLogStore and OSSignposter power in-app diagnostics and performance tracing.
SwiftUI's PhaseAnimator walks a view through ordered animation phases from a single trigger, replacing @State flags and asyncAfter chains. Learn per-phase curves, trigger patterns, and how to combine it with CustomAnimation, plus real-world code for like buttons and toasts.