Combine framework basics — what is a Publisher/Subscriber, and where does it still matter next to async/await?
Combine models values arriving over time as a composable pipeline of operators — and even with async/await handling most one-shot work, it still backs @Published, form validation, and merging multiple live sources.
LEARN BY DOINGPut your intuition to work3 steps
Combine framework basics — what is a Publisher/Subscriber, and where does it still matter next to async/await?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.Combine is Apple's reactive-streams framework, shipped in the same year as SwiftUI (iOS 13), built into the SDK with zero third-party dependency, and aimed squarely at the same problem RxSwift and ReactiveSwift solved before it: modeling values that arrive over time as a declarative, composable pipeline instead of a pyramid of completion-handler closures. A Publisher emits a sequence of values over time and optionally completes or fails; a Subscriber receives them. That is the entire mental model, and everything else in the framework is operators for reshaping the stream between those two roles.
Formally, a Publisher declares an Output type and a Failure type, and the subscription protocol includes backpressure: a subscriber requests demand, the publisher calls receive(subscription:), then delivers a run of receive(_:) value calls, and eventually a single receive(completion:) carrying either .finished or .failure(Error). In practice almost nobody implements this protocol from scratch — day-to-day Combine code is composing publishers other APIs already hand you.
Those everyday building blocks are worth naming precisely because they show up constantly: @Published (which is literally how ObservableObject's automatic objectWillChange firing works under the hood — every @Published property is a Combine publisher wired into that mechanism), PassthroughSubject and CurrentValueSubject for manually-driven pipelines, NotificationCenter.default.publisher(for:), Timer.publish, and URLSession.dataTaskPublisher. On top of those, a small vocabulary of operators does the real work: .map, .filter, .debounce(for:scheduler:), .removeDuplicates(), .combineLatest, .merge, .flatMap, .receive(on:) / .subscribe(on:) for hopping threads, and finally .sink(receiveValue:) or .assign(to:) to actually consume the stream. Combine's real value is chaining several of these into one declarative pipeline instead of nesting completion handlers by hand.
For the common case — "make one request, await one value, done" — async/await (Swift 5.5+) is simply the better tool, and most greenfield code in 2025 reaches for it first: no AnyCancellable to store and risk losing track of, no operator vocabulary to learn for a single value, and structured concurrency handles cancellation automatically via the task tree. A chain that used to read URLSession.dataTaskPublisher(for:).map(\.data).decode(...).sink { }.store(in:&cancellables) collapses to one line: let (data, _) = try await URLSession.shared.data(for: request).
@MainActorfinal class ProfileLoader: ObservableObject {@Published private(set) var profile: Profile?func load(id: String) async {do {let (data, _) = try await URLSession.shared.data(from: URL(string: "https://api.example.com/profile/\(id)")!)profile = try JSONDecoder().decode(Profile.self, from: data)} catch {profile = nil}}}struct Profile: Decodable {let id: Stringlet name: String}
Combine still earns its place in production code in a specific, narrower set of situations. The clearest one is merging more than one live source into a single derived value — a search field that debounces text input, combines the debounced term with a separate filter-toggle publisher, and automatically cancels whatever request was in flight the moment either input changes again. That is a combineLatest + debounce + flatMap problem, and it does not map cleanly onto a single await, because there is no single moment to await — new values keep arriving from two independent sources indefinitely.
import Combineimport Foundationfinal class SearchViewModel: ObservableObject {@Published var query: String = ""@Published private(set) var results: [String] = []private var cancellables = Set<AnyCancellable>()init(searchService: SearchService) {$query.debounce(for: .milliseconds(300), scheduler: RunLoop.main).removeDuplicates().filter { !$0.isEmpty }.flatMap { term insearchService.search(term).catch { _ in Just([]) }}.receive(on: RunLoop.main).assign(to: \.results, on: self).store(in: &cancellables)}}protocol SearchService {func search(_ term: String) -> AnyPublisher<[String], Error>}
Beyond merging sources, Combine also still matters because @Published/ObservableObject is built on it regardless of whether the rest of a codebase touches the framework directly, because a lot of UIKit interop code written between 2019 and 2023 is Combine-based and won't be rewritten just to modernize syntax, and because long-lived, genuinely imperative event streams — NotificationCenter observation, a repeating Timer-driven UI tick — don't fit the "await once" shape at all, since there is no single value to wait for. Swift's own AsyncSequence/AsyncStream is the async/await-native answer to "a stream of many values," and it is steadily narrowing Combine's remaining territory, but the standard library ships no built-in equivalent to combineLatest or debounce — the Swift Async Algorithms package covers some of that gap, unevenly.
The signal an interviewer is actually listening for is precision, not allegiance: being able to say exactly where each tool wins — one-shot versus many values, single source versus several merged sources — reads as far more senior than either "Combine is legacy, don't use it" or "I use Combine for everything."