Combine is Apple's reactive framework (iOS 13+): a Publisher emits values over time followed by one completion, operators transform that stream, and a Subscriber at the end requests and receives the values.
Reactive programming: you describe how values flow and change instead of wiring callbacks and mutable flags by hand. Combine is Apple's typed take on the RxSwift idea.
**Publisher<Output, Failure>**: both types are part of the signature. Failure == Never means the stream cannot fail, and the compiler checks that publishers you join agree on both types.
Operators: methods such as map, filter and debounce that return a new publisher. They are lazy: nothing runs until someone subscribes.
**Subscriber**: the end of the chain. You rarely write one by hand; sink and assign cover almost every case.
**Subscription**: the link between the two. It carries demand and is cancelled through the AnyCancellable you keep.
In 2026 Combine is stable but barely evolving: new code leans on async/await, AsyncSequence and @Observable, while Combine stays in existing code bases and in time-based stream composition.
iOSSenior
Combine
Publishers, Subscribers, operators, Subjects, bridging with async/await
Questions closed
0 / 24
- 0Review
- 1Asked often
Questions
PassthroughSubject forwards each value only to whoever is subscribed at that moment and keeps nothing; CurrentValueSubject stores the latest value, exposes it as .value and hands it to every new subscriber immediately.
Both are subjects: publishers you push into with send(_:) and finish with send(completion:). They are the bridge from imperative code (a delegate, a button handler) into a pipeline.
Use PassthroughSubject for events (a tap, "item saved") and CurrentValueSubject for state (a loading flag, the current user).
The classic bug: sending into a PassthroughSubject before anyone has subscribed. The value is simply lost.
After send(completion:) a subject ignores further values, and new subscribers receive only the completion.
Keep the subject private and expose subject.eraseToAnyPublisher(), so callers can listen but cannot send.
debounce waits until the upstream has been quiet for the given interval and then emits the last value; throttle emits at most one value per interval while values keep arriving.
**debounce(for:scheduler:): the search field: send the request once the user stops typing. If values never pause, it never emits.
throttle(for:scheduler:latest:)**: scroll position, progress, repeated taps. latest: true emits the newest value of each window, false the first one.
Both take a scheduler: DispatchQueue.main for UI work, and an injected scheduler in tests.
Put removeDuplicates() after debounce, so typing "swift", deleting a letter and retyping it does not fire the same request twice.
AnyCancellable is the token a subscription returns: it cancels the subscription when you call cancel() or when it is deallocated, so a pipeline lives exactly as long as someone holds that token.
Not storing it: the token is released at once and the subscription is cancelled. A synchronous publisher such as [1, 2, 3].publisher still delivers during the sink call, which is why the bug hides in playgrounds and shows up with a network request that never answers.
**store(in: &bag)**: put tokens in a Set<AnyCancellable> owned by the view model or view controller. When the owner is deallocated, the set goes with it and every subscription is cancelled.
Cancelling one early: keep it in its own AnyCancellable? property. Assigning a new value releases the old token, which cancels the previous request.
Retain cycles: the owner holds the bag, the bag holds the subscription, the subscription holds the closure. A closure that captures self strongly closes the loop, so use [weak self] in sink.
Completion frees memory: a finished publisher releases its closures; cycles bite with endless streams such as @Published, subjects and timers.
assign(to: &$property) returns no token at all: its lifetime is tied to the @Published property.
assign(to:on:) keeps a strong reference to the object it writes to, and the returned cancellable is stored in that same object, so self → bag → subscription → self is a retain cycle that lasts as long as the upstream does not finish. assign(to: &$title) ties the lifetime to the property instead and creates no cycle.
**sink**: runs a closure for each value and, optionally, for the completion. You choose what to capture, so [weak self] avoids the cycle.
**assign(to:on:): writes each value to a key path and retains the target. It is safe only when the target does not own the cancellable.
assign(to: &$title)** (iOS 14+) writes into a @Published property and ties the subscription's lifetime to that property. It returns no cancellable and creates no cycle; this is the fix here.
The cycle breaks only when the upstream completes. @Published, subjects, notifications and timers never complete on their own, which is why this leak passes code review.
Both assign variants require Failure == Never, so deal with errors first with replaceError(with:) or catch.
The subscription is created in viewWillAppear, which runs every time the screen comes back, and each new one is added to bag while the old ones stay alive. After three visits three sinks run, so every update renders and logs feed_shown three times.
Why it accumulates: viewWillAppear fires on every pop back, tab switch and dismissal of a full-screen modal, but the view controller itself lives on, so bag only grows: nothing cancels the old AnyCancellables until deinit.
It costs more than a log line: $posts replays its current value on subscribe, so every return also re-renders at once; with reloadData or a request in the sink, the work multiplies with each visit.
Fix: subscribe once in viewDidLoad (or bind in init of the owner). If the subscription must live only while the screen is visible, call bag.removeAll() in viewDidDisappear.
Same bug elsewhere: a cell that subscribes in configure(with:) without cancelling in prepareForReuse collects one more sink on every reuse.
Catch it next time: a test that calls viewWillAppear twice, resets the analytics mock, sends one update and asserts it was called exactly once ($posts replays on subscribe, so the count before the reset is not 1); in review, treat store(in:) inside an appearance callback as a red flag.
eraseToAnyPublisher() wraps a publisher in AnyPublisher<Output, Failure> and hides the nested operator types, so an API exposes only what it emits and how it fails, not how the chain is built.
Without it the return type spells out the whole chain, such as Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, Data>, [Post], JSONDecoder>, and every change to the chain changes the public signature.
AnyPublisher lets branches return different chains (Just for a cached value, a network request otherwise) and makes protocol-based mocks easy.
Erasing a subject also hides send, so callers can subscribe but cannot push values.
Since Swift 5.7 Publisher has primary associated types, so some Publisher<[Post], Error> hides the type without a wrapper. Every path must return the same concrete type, and a protocol requirement cannot use some, so AnyPublisher stays the norm at module and protocol boundaries.
Erasure adds a small box and an indirect call. Erase once at the API boundary, not after every operator.
merge interleaves values of the same type from several publishers; combineLatest emits a tuple of the latest value from each input whenever any input changes; zip pairs values by position and emits only when every input has a new one.
**merge**: inputs must share Output and Failure; nothing is paired. Use it for several sources of the same event: push, polling, pull-to-refresh. Publishers.MergeMany takes an array.
**combineLatest**: for state: email plus password to enable a button, filters plus data. It emits nothing until every input has produced at least once, so give an optional input a starting value with prepend.
**combineLatest bursts**: two inputs changing in the same turn produce two outputs; removeDuplicates() or a short debounce collapses them.
**zip**: for matched events, such as waiting for three one-shot requests together (Publishers.Zip3). If one side is faster, zip buffers its extra values without limit: memory grows and the pairs get older.
All three need matching Failure types (mapError, setFailureType(to:)), and a failure in any input fails the combined publisher.
flatMap keeps every inner publisher alive and merges their results, so a slow old request can overwrite a newer one; map followed by switchToLatest cancels the previous inner publisher when a new value arrives, which is what a search field needs.
flatMap runs all inner publishers at once unless you cap it. That is right for independent work, such as uploading five photos.
flatMap(maxPublishers: .max(1)) serialises instead: the next inner publisher starts only after the previous one finishes, which suits a queue where order matters. With a hot upstream such as a subject, values arriving while the inner publisher runs are dropped, so use it with a cold sequence or buffer first.
switchToLatest gives cancel-previous semantics: the old request is cancelled, not just ignored.
Put debounce and removeDuplicates in front so you do not send a request per keystroke, and receive(on:) at the end before touching the UI.
An inner publisher that can fail makes the whole chain fail: after the first error the outer stream is finished too. With a Failure == Never upstream such as $query this compiles silently, so catch errors inside the inner publisher (catch, replaceError).
In async/await the same pattern is a stored Task that you cancel before starting the next one.
It prints 10 and -1: the thrown error is a completion, so replaceError emits one last value and the stream ends, and 3 never arrives. To survive an error, catch it inside a flatMap.
The error operators: tryMap turns a throw into a failure, mapError converts the error type, catch replaces the failed publisher with a fallback one, replaceError(with:) is catch with a single value, and retry(n) subscribes to the upstream again.
These react to a failure that has already finished the upstream: catch and replaceError end the stream with a fallback; retry resubscribes, which helps a network request but loses the failed value of a subject.
To survive an error, move the failing step inside flatMap (or map plus switchToLatest). The inner publisher fails and is replaced, while the outer stream keeps Failure == Never and lives on.
For the UI, turn the failure into a value (a Result or a state enum with a .failed case) so the screen shows the error and keeps working.
Joining publishers needs matching error types: mapError to a shared type, setFailureType(to:) for a publisher that cannot fail.
Log before replaceError, or the error disappears without a trace.
A cold publisher starts its work separately for each subscriber at the moment of subscription; a hot one produces values on its own schedule whether or not anyone listens, so a late subscriber sees only what comes after it joins.
Cold: Just, [1, 2, 3].publisher, URLSession.dataTaskPublisher, Deferred, and operator chains built on them. Two sink calls mean the work runs twice: two network requests.
Hot: PassthroughSubject, NotificationCenter publishers, Timer.publish once connected. Values sent before you subscribe are gone.
Hot with a current value: CurrentValueSubject and @Published replay only the latest value to a new subscriber, nothing earlier.
**Future** is the odd one: it starts when it is created and caches one result for everyone. Wrapping it in Deferred makes it cold.
Cold to hot: share(), multicast and makeConnectable() run the upstream once for many subscribers; that is how you stop duplicate requests.
The practical rule: subscribe to a hot publisher before you trigger the action that sends into it.
CurrentValueSubject stores the last value and sends it to every new subscriber, so each screen that subscribes in viewDidLoad gets the old message at once and shows the toast again. A one-shot event needs PassthroughSubject, which stores nothing, and only one screen must present it.
Who presents: a subject delivers to every live subscriber, and every screen in the navigation stack subscribed in viewDidLoad and stays subscribed while hidden. Even without the replay, three stacked screens would show the toast three times. The filter on viewIfLoaded?.window lets only the visible screen present; a single window-level presenter would work too.
The smell: the nil start value and compactMap { $0 } exist only to skip "no message yet". A subject that needs a fake initial value is usually an event stream.
Fix: PassthroughSubject<String, Never>() delivers each message only to those subscribed at that moment; a message sent while nobody listens is dropped, which is what a toast wants. Sending nil after showing the toast to "reset" it breaks as soon as two subscribers listen.
Same trap: @Published and $property replay the current value too, so an @Published var error: Error? that drives alerts shows the old alert on every new subscription.
Catch it next time: a test that sends a message, subscribes afterwards and asserts nothing arrives.
Future is a publisher that delivers exactly one value or one error through its promise callback; it runs its closure as soon as it is created and caches the result, so Deferred is needed to make it lazy and repeatable.
Most publishers describe work that starts on subscription. Future is a class that starts right away, so creating it is already performing the side effect: in a map, in a stored property, in a factory whose result is thrown away.
Every subscriber gets the same cached result. That makes retry useless: resubscribing replays the cached error instead of calling the closure again.
Deferred { Future { ... } } builds a new Future for each subscription, which restores lazy, per-subscriber behaviour and makes retry work.
Cancelling the subscription only stops delivery; the work inside the closure keeps running. For cancellable work, keep a handle to it and cancel it from handleEvents(receiveCancel:).
Calling promise more than once is ignored: the first call wins.
Use Future for one-shot callback APIs. If the value already exists, use Just.
receive(on:) sets the scheduler on which values and the completion are delivered to everything below it; subscribe(on:) sets where the upstream receives the subscription, requests and cancellation, which is where the work starts, and does not control delivery (a synchronous upstream emits on that queue anyway).
They read in opposite directions: subscribe(on:) affects everything above it wherever you write it, receive(on:) affects everything below it.
Put receive(on: DispatchQueue.main) as late as possible, right before the sink or assign that touches the UI. Placed early, it drags expensive steps such as JSON decoding onto the main thread.
subscribe(on:) is useful for a synchronous upstream: a blocking file read inside Deferred, a Core Data fetch. It is wasted in front of URLSession.dataTaskPublisher, which already works and delivers on its own queue.
Every receive(on:) costs a queue hop per value, so two of them in one chain double the work for nothing.
Prefer DispatchQueue.main over RunLoop.main for UI: the run loop scheduler stops delivering while a scroll view is being dragged.
It prints 0 0 and then 2 0: $items emits the current value on subscription, and @Published publishes from willSet, so the sink runs before the property is written and self.items still holds the old array.
ObservableObject works the same way, and its publisher is named for it: objectWillChange. SwiftUI is fine with that, because it only marks the view dirty and reads the properties on the next render.
The problem appears in hand-written sinks that read back from the object: the new items arrive next to other properties that have not been updated yet.
Fix 1: use the value you were given, and bring in other state with combineLatest instead of reading self.
Fix 2: receive(on: DispatchQueue.main) delivers on a later turn, after the property is written. It works, but it relies on timing rather than a guarantee.
Fix 3: publish after the fact yourself: a CurrentValueSubject fed from didSet.
Add dropFirst() if you want only changes, not the value at subscription time.
Apple ships a short list (NotificationCenter, KVO, Timer, URLSession and your own @Published properties), and there is no built-in publisher for UIControl, so for a button you wrap UIAction in a subject yourself.
NotificationCenter.default.publisher(for:) covers system events; the keyboard frame notifications are the classic UIKit case.
object.publisher(for: \.keyPath) bridges KVO on Apple's objects, such as WKWebView.estimatedProgress or AVPlayerItem.status.
Timer.publish(every:on:in:) is a connectable publisher: it emits nothing until autoconnect() or connect().
URLSession.dataTaskPublisher(for:) fits a screen that is already Combine; new code calls the async method instead.
For a text field, use UITextField.textDidChangeNotification or wrap the .editingChanged event the same way as a button.
Keep every cancellable in a Set<AnyCancellable> owned by the view controller, so the whole graph is torn down with the screen.
The timer was scheduled in RunLoop.Mode.default, and while a finger drags a scroll view the main run loop runs in tracking mode, so timers in the default mode do not fire; schedule it with in: .common instead.
.common covers both the default and the tracking modes, so the timer keeps firing during the drag. That one argument is the whole fix.
Timer.publish(every:tolerance:on:in:) returns a connectable publisher. Without .autoconnect() or connect() it never fires at all, which is the other common "my timer is dead" bug.
The same trap exists on delivery: receive(on: RunLoop.main) also pauses during tracking, while DispatchQueue.main is not a run loop and is unaffected.
Give a visible timer a tolerance so the system can group wake-ups and save battery.
Do not count ticks to measure time. The app can be suspended between them; store a deadline Date and recompute the remaining time on every tick.
A publisher becomes an AsyncSequence through .values, and async work becomes a publisher through Deferred { Future { ... } } or a subject fed from a Task; Combine is still the better tool for time-based composition of several streams.
Publisher to async: publisher.values returns AsyncPublisher (or AsyncThrowingPublisher when the publisher can fail); read it with for await. Cancelling the task cancels the subscription.
Async to publisher: wrap the call in Deferred { Future { promise in Task { ... } } }. Cancelling the subscription does not cancel that Task, and Swift 6 mode rejects capturing the non-Sendable promise in a Task without a workaround.
Mixing: let Combine do the timing (debounce, removeDuplicates) and start an async call from the sink, keeping the Task so you can cancel the previous one.
Where Combine still wins: debounce, throttle, combineLatest and share over several live streams, with cancellation built in; existing ObservableObject code; SDK APIs that only vend a publisher.
Where async wins: one-shot requests, sequential steps, and code checked by Swift 6 strict concurrency, which Combine predates. Swift Async Algorithms adds debounce, merge and combineLatest for AsyncSequence.
Tag each stage with print("name") or handleEvents and read the lifecycle log: the first stage that stops logging values is where the chain died, and its last event says how.
print(_:) logs the subscriber protocol: receive subscription, request unlimited, receive value, receive finished or receive failure, receive cancel. Give each stage its own tag.
**No receive subscription**: nobody subscribed: the sink is missing, or the publisher is connectable and connect() was never called.
**receive subscription followed straight away by receive cancel**: the AnyCancellable was dropped (not stored), or its owner was deallocated.
**receive finished or receive failure**: an upstream completed. Completions are final, so everything sent later goes nowhere.
Inside flatMap or switchToLatest, tag the inner publisher too: an inner failure completes the outer chain, which looks exactly like the source going quiet.
print writes to stdout, so in a shipped build use handleEvents(receiveSubscription:receiveOutput:receiveCompletion:receiveCancel:receiveRequest:) and send the events to Logger.
breakpointOnError() stops the debugger in the frame that produced the failure.
Three times, because each subscription runs the whole chain again; share(), multicast or makeConnectable() make the subscribers use one upstream execution.
A publisher is a recipe, not a running stream. Subscribing starts the work, and each subscriber gets its own subscription and its own upstream.
share() is multicast through a PassthroughSubject plus autoconnect(). Subscribers that attach before the first value share one execution; one that attaches after a value has been sent misses it.
share() is reference counted: when the last subscriber cancels, the upstream is cancelled, and the next subscriber starts a fresh execution. It shares, it does not cache.
makeConnectable() with an explicit connect() removes the race: attach every subscriber first, then start the work. Keep the returned Cancellable; dropping it stops the upstream. It is available only when Failure == Never, so handle errors first.
Combine has no replaying share. For late subscribers, keep the result in a CurrentValueSubject you own, or cache it in the data layer.
In async/await the same "one request in flight for many callers" needs an actor that stores the running Task and lets every caller await it.
Backpressure means the subscriber tells the publisher how many values it can take through Subscribers.Demand; sink and assign request .unlimited up front, so the protocol is effectively switched off and every value is pushed straight through.
How demand works
The subscriber receives a Subscription and calls request(_:). Each receive(_:) returns additional demand. Demand only adds up, so .unlimited cannot be taken back.
Demand only slows publishers that produce on request: a sequence, or your own pull-based source.
Hot sources ignore it
A socket, a timer or a notification produce on their own clock. A PassthroughSubject drops values for a subscriber with no demand, and a CurrentValueSubject keeps only the latest.
Memory growth usually comes from a queue nobody named: receive(on: DispatchQueue.main) schedules one block per value onto a main queue that is already behind.
What to do instead
Cut the rate before the expensive stage: throttle(latest:), debounce, or collect(.byTime(_:_:)) to batch updates.
flatMap(maxPublishers: .max(n)) caps concurrent inner work.
buffer(size:prefetch:whenFull:) bounds a queue only when something downstream asks for limited demand; behind a sink it never fills.
Write your own Subscriber only when the downstream is truly pull-based: request .max(1) and ask for the next value when the work is done.
A custom operator is almost always an extension on Publisher that composes existing operators and returns AnyPublisher or some Publisher; a hand-written Publisher with its own Subscription is worth it only for a new source that must honour demand and cancellation itself.
Composition first: extension Publisher where Output == String with a method chaining debounce and removeDuplicates is a full custom operator. It inherits correct demand, cancellation and thread handling from the operators inside.
A source without a publisher: Combine has no Observable.create like RxSwift. Before writing types, try a PassthroughSubject cleaned up in handleEvents(receiveCancel:), or Deferred { Future { ... } } for one value.
The contract of a hand-written one: receive(subscriber:) hands over a Subscription; the subscription emits only while demand is left, adds the demand each receive(_:) returns, sends at most one completion and nothing after it, and drops the subscriber on cancel() so it is released.
Where it goes wrong: request(_:) and cancel() can arrive from different threads and re-entrantly from inside receive(_:). Production code guards state with a lock and never calls the subscriber while holding it.
Coming from RxSwift: Combine has typed failures and first-party support, but fewer operators: no withLatestFrom and no replaying share, so teams write those or add CombineExt.
Inject the scheduler instead of hard-coding DispatchQueue.main, and pass a test scheduler with a virtual clock that the test moves forward by hand.
Every time-based operator takes a Scheduler. Make it a dependency: production passes the main queue, the test passes a controllable one.
Combine ships ImmediateScheduler, which is enough for receive(on:) but not for debounce or throttle, because those need time to pass. Teams write a small test scheduler or use Point-Free's combine-schedulers package, which provides DispatchQueue.test and the type-erased AnySchedulerOf<DispatchQueue>.
Without a package, make the type generic over S: Scheduler; the erased type only keeps signatures simple.
Waiting on an expectation with a real delay makes the suite slow and flaky, and flaky tests get disabled.
Assert behaviour, not operators: three fast keystrokes produce one request, a repeated query produces none, a new query cancels the one in flight.
Keep the cancellable alive for the whole test, or the pipeline is torn down before anything is recorded.
Combine predates Sendable and actor isolation, so Swift 6 rejects values and closures that cross isolation boundaries through it, and it adds runtime checks that crash a main-actor sink fed from a background queue.
Compile-time errors
Future { promise in Task { ... } }: promise is not Sendable, so capturing it in a Task is rejected.
static let with a non-Sendable subject or publisher is flagged as not concurrency-safe.
Non-Sendable values sent from a sink into Task { @MainActor in ... } or into an actor.
Runtime traps
A closure written inside a @MainActor type inherits main-actor isolation. Swift 6 inserts a check into closures handed to pre-concurrency APIs, so a sink that receives a value on a background queue now traps with "Incorrect actor executor assumption" instead of racing silently.
receive(on: DispatchQueue.main) before that sink satisfies the check, but the compiler cannot prove it, so the bug shows up only when that path runs.
Fixes, in order of preference
Deliver on main right before main-actor code, or bind with assign(to: &$property).
Make the values that cross boundaries Sendable, usually a struct instead of a class.
At the edge, leave Combine: read publisher.values inside a main-actor Task.
@preconcurrency import Combine silences diagnostics for a migration, not as a fix. Migrate module by module.