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.

ContextWhat it usually means
Reads “publisher / subscriber”Has the reactive-streams mental model
Reads “@PublishedHas used Combine through SwiftUI
Reads “AnyCancellableKnows 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. @Published properties on ObservableObject produce 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 @Observable but 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

  1. “Combine is dead.” Marked-as-legacy in mindshare, fully maintained by Apple. SwiftUI’s @Published is 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.
  2. sink retains the publisher.” It doesn’t — the returned AnyCancellable is what holds the subscription. Don’t store it and the pipeline dies.
  3. “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.
  4. PassthroughSubject is the same as CurrentValueSubject.” They differ: PassthroughSubject doesn’t store a current value (new subscribers don’t get the last emission); CurrentValueSubject does. Use the right one or you’ll spend a debugging session on “the view sometimes shows the old data.”
  5. async/await replaces 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. AsyncSequence is 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: .flatMap on a publisher of fast-changing values can pile up unfinished inner subscriptions. Use .switchToLatest() (or the flatMap(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 on ObservableObject classes so SwiftUI views can subscribe to changes via @ObservedObject/@StateObject.

Mid: “Debounce vs throttle — when do you use each?”

debounce waits 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. throttle emits 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 + @Published with @Observable and ordinary stored properties. Replace the flatMap API call with an async method on the model that starts a Task cancelled on each new query. Keep debounce semantics by using AsyncStream or by tracking the query and Task.sleep(for: .milliseconds(300)) with Task.checkCancellation() before the API call. The result is more code but linearly readable. Migrate incrementally: keep the form-validation combineLatest pipeline 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