Combine
Publishers, Subscribers, operators, Subjects, bridging with async/await
- 01
What is Combine? How do Publishers, Subscribers, and Operators relate?
MediumCombine models asynchronous work as a typed pipeline built from four roles.
import Combine let publisher = [1,2,3,4,5].publisher .map { $0 * 2 } .filter { $0 > 4 } .eraseToAnyPublisher() … - 02
What are Subjects in Combine? PassthroughSubject vs CurrentValueSubject.
MediumSubjects are publishers you can imperatively send values into — the bridge between non-Combine code and a Combine pipeline.
import Combine // Event subject — fire-and-forget events let tapSubject = PassthroughSubject<Void, Never>() let bag = tapSubject.sink { print("tapped") } tapSubject.send() … - 03
Which Combine operators cover transformation, combining streams, rate limiting and scheduling?
MediumCombine operators group into five jobs: transform, filter, combine, rate-limit and schedule.
import Combine // Typeahead pipeline class SearchVM: ObservableObject { @Published var query = "" @Published var results: [Result] = [] … - 04
How does SwiftUI observe an ObservableObject, and what has replaced that pattern?
MediumSwiftUI observes a Combine-based view model through
ObservableObjectand@Published.import SwiftUI import Combine final class CartVM: ObservableObject { @Published private(set) var items: [Item] = [] @Published var coupon: String = "" … - 05
How do Combine and Swift Concurrency (async/await) interoperate?
HardCombine and async/await interoperate in both directions, and Apple's direction of travel points from Combine toward async/await.
import Combine // Publisher → async let publisher = url.dataTaskPublisher.map(\.data) for try await data in publisher.values { print(data.count) … - 06
Which Combine mistakes silently break a pipeline, and how do you avoid them?
HardMost Combine bugs come from a pipeline that was never retained, retained too strongly, or delivered on the wrong scheduler.
// Forgot to store — pipeline silently dies func bad() { somePublisher.sink { _ in print("never") } // ⚠️ AnyCancellable discarded } // Fix … - 07
A search field fires a request per keystroke and stale results overwrite fresh ones. Which operator fixes it?
MediumSwitching to the latest inner publisher is the fix, because
flatMapkeeps every request alive and merges their results, so a slow early response can land after a fast late one.final class SearchViewModel: ObservableObject { @Published var query = "" @Published private(set) var results: [Match] = [] private var bag = Set<AnyCancellable>() init(api: SearchAPI) { … - 08
One expensive publisher, three subscribers. How many requests fire, and how do you share the work?
HardThree, because every subscription runs the whole chain again unless you deliberately multicast the publisher.
let request = URLSession.shared .dataTaskPublisher(for: url) .map(\.data) .handleEvents(receiveSubscription: { _ in print("network hit") }) // Three subscribers, three requests … - 09
How do you test a debounced Combine pipeline without sleeping in the test?
HardInject the scheduler instead of hard-coding a queue, so the test can advance time by hand rather than waiting for it.
final class SearchViewModel { @Published var query = "" private(set) var results: [Match] = [] private var bag = Set<AnyCancellable>() init(api: SearchAPI, scheduler: AnySchedulerOf<DispatchQueue>) { … - 10
Swift 6 language mode lights up a Combine-heavy module with errors. What is failing and how do you fix it?
HardCombine predates Sendable and has no isolation model, so almost every error is a closure carrying values across an isolation boundary the compiler will no longer take on trust.
// Before: mutating main-actor state inside a sink final class FeedViewModel { @MainActor private(set) var posts: [Post] = [] private var bag = Set<AnyCancellable>() func start(_ upstream: AnyPublisher<[Post], Never>) { … - 11
In a UIKit screen, which Combine publishers does the SDK actually hand you?
MediumApple vends a short list: notifications, KVO, timers, URLSession tasks and your own
@Publishedproperties — everything else you write yourself.final class ComposeViewController: UIViewController { private var bag = Set<AnyCancellable>() private let sendButton = UIButton(type: .system) private let textField = UITextField() override func viewDidLoad() { … - 12
Why won't these two publishers combine, and how do you keep a stream alive after an error?
MediumFailure is part of a publisher's type, so
combineLatestandziprefuse to compile until both sides agree on it, and any failure ends the subscription permanently.// Does not compile: Failure types disagree // let merged = Publishers.CombineLatest(profilePublisher, settingsPublisher) // Fix 1 — map both to a shared error type let merged = profilePublisher .mapError { AppError.network($0) } … - 13
You wrap a network call in a Future and the request fires before anyone subscribes, then never runs again — why?
MediumFutureruns its closure once, at initialization, and caches that single result for every subscriber — it is a promise, not a recipe.// Eager: the closure runs at init, not at subscribe func badFetch(id: String) -> AnyPublisher<User, Error> { Future { promise in print("request fired") // prints before anyone subscribes Task { do { promise(.success(try await api.user(id))) } … - 14
JSON decoding still blocks the main thread even though the pipeline ends with receive(on: .main) — what did you get wrong?
Mediumreceive(on:)only moves the operators written below it, so a decode placed after the hop runs on the main queue — the hop must be the last step, not an early one.// Wrong: the hop to main happens before the expensive work URLSession.shared.dataTaskPublisher(for: url) .receive(on: DispatchQueue.main) .map(\.data) .decode(type: [Post].self, decoder: JSONDecoder()) // decodes ON the main thread .replaceError(with: []) … - 15
Inside a sink on $items you read the view model's items and get the previous array — what is Combine doing?
Medium@Publishedpublishes fromwillSet, so your subscriber runs before the stored property is written: the closure argument is the new value while the property still holds the old one.final class CartViewModel: ObservableObject { @Published var items: [Item] = [] @Published var coupon: String? @Published private(set) var total: Total = .zero private var bag = Set<AnyCancellable>() … - 16
A Timer.publish countdown freezes while the user drags a scroll view and jumps forward when they let go — why?
MediumThe timer was enrolled in
RunLoop.Mode.default, and while a finger is dragging a scroll view the main run loop spins in the tracking mode, so nothing registered in the default mode gets a turn.// Freezes while a scroll view is being dragged: default mode only let stalling = Timer.publish(every: 1, on: .main, in: .default).autoconnect() // Keeps ticking during tracking private let deadline = Date().addingTimeInterval(300) private var bag = Set<AnyCancellable>() … - 17
A socket pushes thousands of messages a second into a sink, the UI falls behind and memory climbs — Combine is demand-driven, so why?
Hardsinkandassignrequest.unlimiteddemand, so from the moment you attach one the backpressure protocol is switched off and every value is pushed straight through.// sink asks for unlimited demand — every message becomes a queued main-thread block socket.messages .receive(on: DispatchQueue.main) .sink { [weak self] in self?.rows.append($0) } // main never catches up, memory climbs .store(in: &bag) … - 18
A dashboard zips a fast price feed with a slow status feed, memory grows all day and the numbers lag — what is zip doing?
Hardzippairs by position, so it holds every unmatched element of the faster side in an unbounded buffer and the pairs it does emit get older and older.// Leak with a schedule: prices tick 50x a second, status once a minute prices .zip(status) // ~3000 prices buffered per status update .sink { price, status in render(price, status) } // and the pair is a minute stale .store(in: &bag) … - 19
A sink writes back into the subject that fed it and the app recurses to death — why doesn't Combine stop the reentrancy?
HardCombine delivers synchronously on whatever thread called
send, with no reentrancy guard at all: your sink runs inside thatsend, so writing back re-enters the same subject on the same stack.let events = PassthroughSubject<Event, Never>() // Reentrant: this sink runs INSIDE send, on the same stack events .sink { event in if event.needsFollowUp { … - 20
A pipeline emits twice in production and then goes quiet — how do you find where the events stop?
HardTag each stage with
printorhandleEventsand read the lifecycle log: the first stage that stops printing values is where the chain died, and its last event says how.$query .print("1-query") .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .print("2-debounced") .map { api.search($0).print("3-request") } .switchToLatest() …