Combine vs RxSwift vs Swift collection chains
map, filter, flatMap, reduce. Swift has them on Array. RxSwift has them on Observable. Combine has them on Publisher. The names line up, the chains look the same, and that surface similarity is exactly what makes mixing the three confusing. They are not the same thing.
Three things separate them: sync vs async, eager vs lazy, and whether values arrive over time. Errors, cancellation, threading, and testing all fall out of those.
TL;DR
| Swift collections | Combine | RxSwift | |
|---|---|---|---|
| Type | Array, Sequence, LazySequence |
Publisher, Subscriber |
Observable, Observer |
| Domain | Values in memory | Values over time | Values over time |
| Evaluation | Eager (default), lazy via .lazy |
Lazy (cold) until subscribed | Lazy (cold) until subscribed |
| Direction | Pull (you iterate) | Push (subscribers receive) with demand | Push (subscribers receive) |
| Backpressure | N/A | Yes (Subscribers.Demand) |
No (manual) |
| Errors | throws (sync) or none |
Typed Failure associated type |
Untyped Error |
| Cancellation | N/A | AnyCancellable |
Disposable / DisposeBag |
| Threading | Caller’s thread | subscribe(on:) / receive(on:) |
subscribeOn / observeOn |
| Subjects | N/A | PassthroughSubject, CurrentValueSubject |
PublishSubject, BehaviorSubject, ReplaySubject |
| Platform | All Swift | iOS 13+, macOS 10.15+ | All (third-party) |
| Today’s role | Always relevant | Apple’s official path | Legacy / cross-platform |
The three worlds, in one diagram
The mental model that helps most: collections operate over values in space (what’s already in memory); reactive streams operate over values in time (what arrives later). The operators look identical because both are pipelines, but one runs to completion now, the other runs whenever a value shows up.
flowchart LR
subgraph C["Swift collections (space)"]
direction LR
A1["[1, 2, 3, 4]"] --> A2[".filter { > 1 }"]
A2 --> A3[".map { * 2 }"]
A3 --> A4["[4, 6, 8]"]
end
subgraph R["Reactive streams (time)"]
direction LR
B1["values...<br/>over time"] --> B2[".filter"]
B2 --> B3[".map"]
B3 --> B4["subscriber"]
end
style C fill:#eef,stroke:#669
style R fill:#efe,stroke:#696
In the collection pipeline the entire dataset is known up front; the chain runs to completion synchronously when you call it (or on demand for each element if you wrap with .lazy). In the reactive pipeline the source emits whenever it wants, possibly forever, and operators are templates that get instantiated when somebody subscribes.
Same chain, three implementations
Take a tiny task: “Given a list of numbers, keep the evens, double them, and sum the result.”
1 | // Swift collections: eager, synchronous |
1 | // Combine: lazy, push-based |
1 | // RxSwift: lazy, push-based |
Looks identical. They are not. The collection version runs the moment you write it. The other two build a pipeline description and don’t run anything until sink / subscribe is called. Drop those final lines and Combine/RxSwift produce zero work.
Lazy collections: the closest cousin
Swift collections can be lazy, and that’s the closest sync analogue to a reactive pipeline:
1 | let pipeline = (1...).lazy |
LazySequence is pull-based (the consumer iterates), reactive streams are push-based (the producer emits), but the laziness is the same idea: nothing happens until somebody asks. The remaining gap is time. LazySequence cannot represent “a value will arrive in 200 ms,” only “a value will be computed when iterated.”
Eager vs lazy execution
sequenceDiagram
participant Caller
participant Pipeline
Note over Caller,Pipeline: Swift collection (eager)
Caller->>Pipeline: filter
Pipeline-->>Caller: [2,4,6]
Caller->>Pipeline: map
Pipeline-->>Caller: [4,8,12]
Caller->>Pipeline: reduce
Pipeline-->>Caller: 24
Each operator on Array allocates a new array. Three operators, three intermediate allocations. For small collections this is fine; for large ones, switch to .lazy.
sequenceDiagram
participant Source as Publisher / Observable
participant Op1 as filter
participant Op2 as map
participant Sub as Subscriber
Note over Source,Sub: Combine / RxSwift (lazy, push)
Sub->>Op2: subscribe
Op2->>Op1: subscribe
Op1->>Source: subscribe
Source-->>Op1: 1
Op1-->>Source: (drop)
Source-->>Op1: 2
Op1-->>Op2: 2
Op2-->>Sub: 4
Source-->>Op1: 3
Op1-->>Source: (drop)
Source-->>Op1: complete
Op1-->>Op2: complete
Op2-->>Sub: complete
Subscription chains upstream (the subscriber is at the bottom and asks the source for values), values flow downstream. That direction is the bit reactive newcomers tend to get backwards.
The operator name overlap is a trap
flatMap is the canonical example. Three different things:
Array.flatMap: flatten one level.[[1,2],[3]].flatMap { $0 } == [1,2,3]. Synchronous.Publisher.flatMap: for each value, subscribe to a returned publisher and merge their outputs. Concurrency-bounded bymaxPublishers.Observable.flatMap: project each value into anObservableand merge. RxSwift also hasflatMapLatest(cancels the previous inner) andconcatMap(queues serially). Combine hasswitchToLatestfor the same purpose, applied differently.
Knowing what flatMap returns isn’t enough; you also need to know which flavor you want: merge, switch-to-latest, or concat. Reach for the wrong one and you get either lost values or stale ones.
Errors
1 | // Swift collections |
1 | // Combine: typed errors |
1 | // RxSwift: Error (untyped) |
The practical difference: Combine’s typed Failure lets the compiler tell you when an upstream can or can’t fail. Publisher<Output, Never> is a strong guarantee, and sink(receiveValue:) (no completion handler) is only available when failure is Never. RxSwift erases all errors to Error, so the compiler can’t help you.
A reactive stream also terminates on error: once it errors, it’s done forever. I’ve shipped this bug more than once: catch a transient failure during dev, see one retry succeed, ship, then watch the pipeline silently die in production after the first real error. Use catch / retry / replaceError to keep it alive.
Cancellation
Collections don’t cancel: they’re synchronous, you either let them finish or never call them. Reactive pipelines own real resources (timers, network requests, KVO observers) and must be torn down explicitly.
1 | // Combine |
Both follow the same rule: when the cancellable / disposable is deallocated, the subscription is torn down. Forget to retain it and the chain dies before any values arrive. Forget to release it and you get retain cycles via [weak self] mistakes.
Threading and scheduling
1 | // Combine |
1 | // RxSwift |
Same shape, slightly different vocabulary. The two non-obvious rules apply to both:
subscribe(on:)/subscribeOnaffects work upstream of where you call it (subscription, source emission). Calling it late in the chain is a common bug.receive(on:)/observeOnaffects everything downstream. UIKit/SwiftUI work belongs on main.
Hot vs cold
A cold publisher/observable starts producing on subscription, and each subscriber gets its own run (e.g. a network request fires per subscriber). A hot one is already running; subscribers see whatever’s emitted from now on.
Both libraries provide subjects to bridge the two:
| Need | Combine | RxSwift |
|---|---|---|
| Multicast event bus | PassthroughSubject |
PublishSubject |
| “Current value, plus future” | CurrentValueSubject |
BehaviorSubject |
| Replay last N | .share(replay:) via custom |
ReplaySubject |
The most common bug: subscribing to a cold publisher twice (e.g. binding the same URLSession.dataTaskPublisher to two views) and seeing two requests. Fix with .share() (Combine) or .share(replay:) (RxSwift) to make it hot/multicast.
Backpressure
Combine has it; RxSwift doesn’t.
In Combine, subscribers signal demand: Subscribers.Demand.max(N) or .unlimited. Operators respect that demand and propagate it upstream. This is what makes Combine viable for fast producers (file reads, large CSV streams) without forcing buffering.
RxSwift has no formal backpressure. If your producer is faster than your consumer, you buffer or drop manually with buffer, throttle, debounce, sample. RxJava has Flowable for this; RxSwift never adopted the equivalent.
In practice most app-level reactive code is event-rate-limited (taps, network responses, KVO) and backpressure doesn’t matter. It matters once you stream large datasets through a reactive pipeline.
Testing
1 | // Combine: synchronous via Just / Fail / scheduler injection |
1 | // RxSwift: RxTest with TestScheduler and virtual time |
RxSwift’s RxTest virtual-time scheduler is genuinely excellent for time-based tests (debounce, throttle, retry intervals). Combine’s official testing story is weaker; the community settled on combine-schedulers for the Combine equivalent, and swift-clocks for the async/await side.
Other “values over time” structures in the Swift ecosystem
The three I started with aren’t the whole story. A few more pipeline-shaped APIs you’ll meet in real Swift code:
AsyncSequence / AsyncStream / AsyncThrowingStream
The Swift Concurrency answer to reactive streams. Pull-based (the consumer iterates), but the iteration itself can suspend until the next value arrives:
1 | for await value in numbers.filter({ $0.isMultiple(of: 2) }).map({ $0 * 2 }) { |
AsyncStream lets you bridge any callback-based source (KVO, delegate, NotificationCenter) into AsyncSequence:
1 | let timer = AsyncStream<Date> { continuation in |
Where it lines up with Combine/RxSwift, and where it doesn’t:
AsyncSequence |
Combine Publisher |
|
|---|---|---|
| Direction | Pull (consumer iterates) | Push (with demand) |
| Cancellation | Task cancellation |
AnyCancellable.cancel() |
| Multicast | No (single consumer) | Yes (.share(), subjects) |
| Backpressure | Implicit (consumer suspends) | Explicit (Demand) |
| Operators | map, filter, prefix, compactMap, dropFirst… |
Larger set, plus time operators |
| Time operators | None in stdlib (community: AsyncAlgorithms) |
Built-in (debounce, throttle…) |
| Cold/Hot | Always cold per-iteration | Both, via subjects |
AsyncSequence is closer to LazySequence with suspension points than to Observable. If you only need to consume a stream of values one place, async iteration wins. If you need to fan out to multiple consumers, share state, or use rich time operators, you still want Combine (or AsyncAlgorithms‘s AsyncChannel + multicast helpers).
Combine ↔ AsyncSequence bridges
These exist in both directions and you’ll use them:
1 | // Publisher -> AsyncSequence (built-in) |
In practice, most modern code subscribes once (for await ... in publisher.values) and lets Combine do the multicast / hot-stream side, while consumers stay in async land. The reverse direction is rarer and you build it by hand.
The Observation framework (@Observable, iOS 17+)
Not a pipeline, but worth flagging because it’s often confused with reactive streams. The new @Observable macro replaces ObservableObject + @Published for SwiftUI state observation:
1 | final class ViewModel { |
SwiftUI views auto-track exactly the properties they read: no @Published, no objectWillChange, no Combine subscription. It’s not a stream you can map over; it’s a fine-grained dependency tracker. If you tried to model “give me a stream of count changes” you’d reach for withObservationTracking (a low-level escape hatch) or wrap the property in AsyncStream. For normal SwiftUI work, you don’t need a stream at all.
@Published (the bridge in the middle)
@Published is a Combine Publisher, full stop:
1 | final class VM: ObservableObject { |
The $count projection is a Publisher<Int, Never>. SwiftUI uses it via ObservableObject; you can also subscribe directly. As @Observable takes over, @Published is the “I still want a Combine pipeline off this property” tool.
TCA Effect, EffectPublisher
If you use The Composable Architecture, Effect is a wrapper that started life as a Combine Publisher and migrated to async/await. Same pipeline shape, narrower role: it represents work a reducer kicks off, with cancellation by ID baked in. Mention it because TCA codebases blur “Combine vs async”: the framework lets you write either inside an Effect.
Result, Optional: also chainable, also not streams
Optional.map and Result.flatMap use the same vocabulary, but each represents a single value (present-or-not, success-or-failure). They’re functor/monad cousins of the others, not stream cousins. Worth knowing because the operator-name overlap can mislead: flatMap on Result<Int, E> doesn’t merge anything, it just chains a fallible step.
Putting them on one axis
flowchart LR
subgraph Single["Single value"]
Opt["Optional<br/>Result"]
end
subgraph Many["Many values, in space"]
Coll["Array / Sequence"]
LSeq["LazySequence"]
end
subgraph Time["Many values, over time"]
Async["AsyncSequence /<br/>AsyncStream"]
Comb["Combine Publisher"]
Rx["RxSwift Observable"]
end
Opt -.same operator vocabulary.-> Coll
Coll -.add laziness.-> LSeq
LSeq -.add suspension.-> Async
Async -.add multicast + push.-> Comb
Comb -.cross-platform variant.-> Rx
Read it left-to-right as a feature ladder: each step adds one capability. Multiple values, then laziness, then time/suspension, then push semantics + multicast. The operator names (map, filter, flatMap) carry across because the underlying algebra is the same; what changes is the runtime behaviour around them.
When to pick what
flowchart TD
Start{Are you operating on<br/>values that exist NOW?}
Start -->|Yes, all in memory| Coll[Swift collections<br/><i>map / filter / reduce</i>]
Start -->|Yes, but huge / infinite| Lazy[".lazy on the sequence"]
Start -->|No - values arrive over time| Time{New code or existing?}
Time -->|New| Async{Need bidirectional /<br/>multicast / hot streams?}
Async -->|No| AA["async/await + AsyncSequence"]
Async -->|Yes| Combine["Combine"]
Time -->|Existing RxSwift codebase| Rx["RxSwift<br/><i>migrate gradually</i>"]
In 2026 the picture is:
- Collections are foundational; you use them every day regardless.
async/await+AsyncSequenceis now the default for “values over time” in new Swift code. Easier to reason about, no subscription lifetime to manage, no operator-name memorisation. Use this first.- Combine is still the right answer when you need true multicast, hot subjects,
@Published, or when you’re glued toURLSession.dataTaskPublisher/NotificationCenter.publisher/ SwiftUI bindings. Apple has slowed down Combine’s evolution but it’s not deprecated and remains everywhere in the system frameworks. - RxSwift is legacy in pure-Swift Apple work. Keep it for codebases that already use it, or for cross-platform sharing with RxJava / RxJS / RxKotlin where the same mental model travels.
Operator quick-reference
The same mental operation in all three:
| Intent | Collection | Combine | RxSwift |
|---|---|---|---|
| Transform | map |
map |
map |
| Filter | filter |
filter |
filter |
| Drop nils | compactMap |
compactMap |
compactMap |
| Flatten one level | flatMap |
flatMap(maxPublishers:) |
flatMap |
| Switch to newest | n/a | map { ... }.switchToLatest() |
flatMapLatest |
| Combine two streams | zip(a, b) |
Publishers.Zip / combineLatest |
Observable.zip / combineLatest |
| Accumulate | reduce(_:_:) |
reduce / scan |
reduce / scan |
| Drop duplicates | n/a (use Set) |
removeDuplicates() |
distinctUntilChanged() |
| Take first | prefix(1) |
prefix(1) / first() |
take(1) |
| Drop first | dropFirst(1) |
dropFirst(1) |
skip(1) |
| Throttle/debounce | n/a | throttle / debounce |
throttle / debounce |
| Side effect | forEach |
handleEvents |
do(onNext:) |
What I reach for
For new code I default to async/await + AsyncSequence. Combine when I actually need multicast, hot subjects, or @Published for SwiftUI. RxSwift only when the codebase already runs on it.