6.7 — Combine
Opening scenario
You inherit a five-year-old codebase. The LoginViewModel is 400 lines and reads like a logic puzzle: .combineLatest, .flatMap, .debounce, .removeDuplicates, .receive(on:), ending in .sink { [weak self] in self?.update($0) }.store(in: &cancellables). You ask the lead, “should we migrate this to async/await?” The lead says, “yes — but slowly, and you still need to know Combine, because the migration is going to take three years and meanwhile UIKit + Combine is half our code.”
Combine is not dead. It’s settled. In 2026, new iOS code rarely starts in Combine; new code is async/await and @Observable. Legacy code and UIKit reactive bridges are full of it. SwiftUI’s @Published is Combine under the hood. Form validation pipelines, search debouncing, and real-time data streams are still cleaner in Combine than in async/await today.
| Context | What it usually means |
|---|---|
| Reads “publisher / subscriber” | Has the reactive-streams mental model |
Reads “@Published” | Has used Combine through SwiftUI |
Reads “AnyCancellable” | Knows memory management is manual |
| Reads “backpressure” | Comes from RxSwift or Reactive Streams |
| Reads “Combine vs async/await” | Has migrated a codebase between the two |
Concept → Why → How → Code
Concept
Combine is Apple’s reactive streams framework, introduced iOS 13 (2019). Three types:
Publisher— emits a stream of values, optional failure, then optional completion.Subscriber— receives values and demand.Subscription— connects them and is the disposal handle.
Most consumers use AnyCancellable (a subscription wrapped for ARC-based disposal) and operators (map, flatMap, combineLatest, debounce, throttle, catch, share) to build pipelines. The same conceptual model as RxSwift, ReactiveSwift, or any Reactive Streams library.
Why (still, in 2026)
- SwiftUI ↔ Combine bridge is built in.
@Publishedproperties onObservableObjectproduce a publisher that SwiftUI auto-subscribes to. - UIKit reactive form patterns (text field validation, button-enabled state from multiple inputs) are still cleaner in Combine.
- Event buses (NotificationCenter wrappers, Realm/Core Data change publishers, Firebase listeners) speak Combine natively or have trivial bridges.
- The codebase you’ve been hired to maintain is full of it.
When not Combine in new code: one-shot async work (use async/await), simple “fetch a thing once” patterns (use URLSession.data), state machines (use @Observable).
How — the building blocks
import Combine
let just = Just(42) // emits 42, completes
let array = [1, 2, 3].publisher // emits each, completes
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
let subject = PassthroughSubject<String, Never>() // imperative push
let current = CurrentValueSubject<Int, Never>(0) // BehaviorSubject equivalent
Subscribe with sink (most common) or assign(to:on:):
var cancellables = Set<AnyCancellable>()
subject
.filter { $0.count > 2 }
.map { $0.uppercased() }
.sink { value in print(value) }
.store(in: &cancellables)
store(in:) retains the subscription; when the Set is deallocated, all subscriptions cancel. Forget this and your subscription is GC’d on the next line.
@Published + ObservableObject
final class SearchViewModel: ObservableObject {
@Published var query = ""
@Published private(set) var results: [SearchResult] = []
@Published private(set) var isLoading = false
private var cancellables = Set<AnyCancellable>()
private let api: SearchAPI
init(api: SearchAPI) {
self.api = api
$query
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.filter { $0.count >= 2 }
.handleEvents(receiveOutput: { [weak self] _ in self?.isLoading = true })
.flatMap { [api] q in
api.search(query: q)
.catch { _ in Just([]) }
.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink { [weak self] results in
self?.results = results
self?.isLoading = false
}
.store(in: &cancellables)
}
}
The classic “type-ahead search with debouncing” pipeline. Three years from now this might be a 60-line async sequence with Task-cancellation, but as one declarative chain it’s hard to beat.
Combining multiple inputs
final class SignupViewModel: ObservableObject {
@Published var email = ""
@Published var password = ""
@Published var confirmPassword = ""
@Published private(set) var isFormValid = false
private var cancellables = Set<AnyCancellable>()
init() {
Publishers.CombineLatest3($email, $password, $confirmPassword)
.map { email, password, confirm in
email.contains("@") && password.count >= 8 && password == confirm
}
.assign(to: &$isFormValid) // self-retaining assign-to-property
}
}
assign(to: &$property) (the inout/key-path variant) is the modern, leak-safe way to feed a publisher into a @Published. No cancellables bookkeeping.
Bridging to async/await
let result = try await urlPublisher
.values
.first(where: { _ in true })
Or simpler, for a single-value publisher:
let value = try await publisher.async() // hand-rolled extension
extension Publisher where Failure == Error {
func async() async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
var cancellable: AnyCancellable?
cancellable = self.sink { completion in
if case .failure(let error) = completion {
continuation.resume(throwing: error)
}
cancellable?.cancel()
} receiveValue: { value in
continuation.resume(returning: value)
cancellable?.cancel()
}
}
}
}
This is how you migrate gradually: leave the Combine pipeline at the source, await its first value from your async code.
Memory management
The two patterns that prevent every Combine leak I’ve seen in code review:
// 1. Always [weak self] in sink closures
.sink { [weak self] value in self?.handle(value) }
// 2. Always .store(in: &cancellables) OR .assign(to: &$published)
Set<AnyCancellable> released → all subscriptions cancelled. If you store a cancellable in a let constant outside a set, it stays alive forever (memory leak); if you don’t store it at all, the publisher cancels immediately.
In the wild
- Most iOS 13–16 era SwiftUI apps use
ObservableObject+@Published. That’s Combine underneath. The migration to@Observable(Chapter 5.4) drops Combine from the SwiftUI surface but the publishers remain available. - Banking and trading apps (Robinhood, Coinbase, Square) lean heavily on Combine for real-time price streams in UIKit.
- Airbnb’s iOS architecture talk (2023) detailed their use of Combine as the spine of their MVVM layer; their 2025 follow-up describes a gradual migration to
@Observablebut says “Combine for streams, Observation for state” remains the heuristic. - Firebase Apple SDK ships Combine publishers as a first-class API. Same for Realm, GRDB, and most modern persistence libraries.
Common misconceptions
- “Combine is dead.” Marked-as-legacy in mindshare, fully maintained by Apple. SwiftUI’s
@Publishedis Combine. New first-party iOS frameworks still ship Combine extensions. Don’t write new business logic in it, but expect to read it for years. - “
sinkretains the publisher.” It doesn’t — the returnedAnyCancellableis what holds the subscription. Don’t store it and the pipeline dies. - “Schedulers don’t matter.” They matter enormously.
.receive(on: DispatchQueue.main)is required before UI mutations. Forgetting it gives you “purple warnings” or random crashes depending on the OS version. - “
PassthroughSubjectis the same asCurrentValueSubject.” They differ:PassthroughSubjectdoesn’t store a current value (new subscribers don’t get the last emission);CurrentValueSubjectdoes. Use the right one or you’ll spend a debugging session on “the view sometimes shows the old data.” - “
async/awaitreplaces all of Combine.” It replaces single-value async work and finite sequences. It does not (yet) replace multi-publisher composition, declarative timing operators (debounce,throttle), or hot streams of events.AsyncSequenceis closing the gap but not there yet.
Seasoned engineer’s take
In 2026 my heuristic is “streams: Combine; one-shots: async/await; state: @Observable.” A SwiftUI form’s validation pipeline is Combine. A func fetchProfile() async throws -> Profile is async/await. The view model’s @Observable state is neither — it’s Observation.
Don’t migrate working Combine code for fashion. The “modernize to async/await” project nobody is paid to ship is the project that breaks in production. Migrate when you touch a file for another reason and the migration is small. Migrate when a Combine pipeline has accumulated more than six operators and is hard to debug. Otherwise: leave it, document it, write tests around it.
The skill that separates senior from staff: knowing when not to be reactive. Half the Combine pipelines I’ve reviewed could be three lines of imperative code in an async function. Reactive composition is a tool, not a moral position. Reach for it when the synchronization is the hard part (multiple async inputs, debouncing, switching latest); reach past it when the work is a linear sequence.
TIP:
.print("label")is the best Combine debugging tool nobody uses. Drop it anywhere in a pipeline to log subscriptions, demands, values, completions, and cancellations to the console. Find the broken operator in 30 seconds.
WARNING:
.flatMapon a publisher of fast-changing values can pile up unfinished inner subscriptions. Use.switchToLatest()(or theflatMap(maxPublishers: .max(1))variant) for “cancel the previous in-flight request when a new value arrives” — the canonical pattern for type-ahead search.
Interview corner
Junior: “What is @Published?”
A property wrapper that wraps a value and exposes a Combine publisher (
$propertyName) emitting whenever the value changes. Used onObservableObjectclasses so SwiftUI views can subscribe to changes via@ObservedObject/@StateObject.
Mid: “Debounce vs throttle — when do you use each?”
debouncewaits a quiet period after the latest emission and then emits the last value; perfect for type-ahead search where you only want to query after the user stops typing.throttleemits the first (or last) value within a window and ignores the rest; perfect for scroll position events where you want at most one update every N milliseconds regardless of how fast events arrive.
Senior: “Walk me through migrating a Combine-heavy SearchViewModel to @Observable + async/await.”
Replace
ObservableObject+@Publishedwith@Observableand ordinary stored properties. Replace theflatMapAPI call with anasyncmethod on the model that starts aTaskcancelled on each new query. Keepdebouncesemantics by usingAsyncStreamor by tracking the query andTask.sleep(for: .milliseconds(300))withTask.checkCancellation()before the API call. The result is more code but linearly readable. Migrate incrementally: keep the form-validationcombineLatestpipeline in Combine (it’s clean), migrate only the network-fetching side. Ship behind a feature flag, A/B for one release.
Red flag: “We use Combine because it’s the modern way.”
Tells the interviewer the candidate adopts tech for trend reasons. The modern way in 2026 for new state is
@Observable; Combine remains valid but for specific reasons.
Lab preview
Lab 6.3 — Production Network Layer builds the network client end-to-end with async/await; the stretch goal adds Combine publisher wrappers for callers that haven’t migrated yet.
Next: Caching Strategies