I remember the exact moment Subjects stopped feeling like magic and started feeling like tools I could actually control.
I had been using RxSwift for a while. Observables made sense. Operators were powerful. Binding worked. But every time I needed to push values into a stream myself — from a network callback, a button action, or some internal state change — I just reached for PublishSubject and moved on. It worked… until it didn’t.
Then one day I sat down and actually looked at the different types of Subjects. That was the turning point. Suddenly a lot of the weird behaviour I’d been fighting made sense, and I started writing cleaner reactive code.
This article is the deep-dive I wish I had back then.
Quick Refresher: What Even Is a Subject?
A Subject is both an Observable and an Observer.
That means:
You can subscribe to it (like a normal Observable)
You can also call .onNext(), .onError(), and .onCompleted() on it yourself
This dual nature is extremely useful, but it’s also where a lot of the confusion (and bugs) come from.
There are four main types in RxSwift:
- PublishSubject
- BehaviorSubject
- ReplaySubject
- AsyncSubject
Let’s go through each one properly.
1. PublishSubject – The Event Bus
let publishSubject = PublishSubject<String>()
publishSubject.onNext("A") // Nobody is listening yet → gone forever
let subscription = publishSubject.subscribe(onNext: { print($0) })
publishSubject.onNext("B") // prints "B"
publishSubject.onNext("C") // prints "C"
Key behaviour:
- Starts empty
- Only emits values to subscribers after they subscribe
- Does not keep any history
- Perfect for discrete events (button taps, notifications, “something just happened”)
When I reach for it:
- UI events that shouldn’t replay
- One-shot signals between layers
- Any time I truly don’t care about past values
Common pitfall:
People sometimes use PublishSubject for state. Then a late subscriber gets nothing and the UI stays blank. That’s usually a sign you needed a BehaviorSubject instead.
2. BehaviorSubject – State That Always Has a Current Value
let behaviorSubject = BehaviorSubject(value: "Initial")
behaviorSubject.subscribe(onNext: { print("Subscriber 1:", $0) })
// Immediately prints: Subscriber 1: Initial
behaviorSubject.onNext("Updated")
behaviorSubject.subscribe(onNext: { print("Subscriber 2:", $0) })
// Immediately prints: Subscriber 2: Updated
Key behaviour:
- Requires an initial value
- Always holds the latest value
- New subscribers get that latest value immediately
- After that, they get new values as they arrive
- This is the Subject I use the most in real apps.
Typical use cases:
- Current user profile
- Selected tab or filter
- Loading / error / success state of a screen
- Any piece of state that the UI needs right now
Important notes:
Calling .onCompleted() or .onError() terminates it permanently. After that, new subscribers only get the terminal event.
If you need something that never completes and is easier to work with from the UI side, many people prefer BehaviorRelay (from RxRelay). But under the hood it’s still built on BehaviorSubject.
Common pitfall:
Creating a BehaviorSubject with a dummy initial value just to satisfy the compiler, then immediately overwriting it. That dummy value often leaks into the UI for a brief moment.
3. ReplaySubject – “Here’s What You Missed”
let replaySubject = ReplaySubject<String>.create(bufferSize: 2)
replaySubject.onNext("A")
replaySubject.onNext("B")
replaySubject.onNext("C")
replaySubject.subscribe(onNext: { print($0) })
// prints:
// B
// C
Key behaviour:
- Keeps a buffer of the last N values
- New subscribers receive those buffered values first, then live values
- You can also create an unbounded version with .createUnbounded(), but be careful with memory
When it’s useful:
- You need a short history (last few locations, last few search results, etc.)
- Multiple subscribers joining at different times should all see recent activity
- Debugging or logging streams where context matters
Common pitfall:
Using a large or unbounded buffer without thinking. It’s easy to accidentally keep a lot of objects alive and create memory pressure.
4. AsyncSubject – The Final Result Only
let asyncSubject = AsyncSubject<String>()
asyncSubject.subscribe(onNext: { print($0) })
asyncSubject.onNext("A")
asyncSubject.onNext("B")
asyncSubject.onNext("C")
asyncSubject.onCompleted()
// Only now does it print: C
Key behaviour:
- Ignores everything until the sequence completes
- Then emits only the last value (if any) and completes
- If it errors, subscribers get the error instead
This one is the least used in day-to-day iOS work, but it’s perfect for “I only care about the final answer” scenarios — like a long-running calculation or a multi-step process that should only notify when fully done.
Common pitfall:
- Forgetting to call .onCompleted(). Without it, nothing ever emits.
- Side-by-Side Comparison
Real-World Pitfalls I’ve Hit (and How to Avoid Them)
1. Subjects and Memory Leaks
Subjects themselves don’t cause retain cycles, but the way we hold them does.
class ViewModel {
let events = PublishSubject<Void>()
func setup() {
events
.subscribe(onNext: { [weak self] in // ← easy to forget
self?.doSomething()
})
.disposed(by: disposeBag)
}
}
Always capture self weakly inside the subscription if the subject lives on self.
2. Completing a Subject Too Early
Once a Subject receives .onCompleted() or .onError(), it’s done. Forever. New subscribers only get the terminal event.
If you need a long-lived stream of events, never complete the Subject unless you’re intentionally shutting it down.
3. Using the Wrong Subject for State
This is the most common intermediate mistake.
- Need the current value right away when someone subscribes? → BehaviorSubject
- Pure event that shouldn’t replay? → PublishSubject
- Need a few recent values? → ReplaySubject
Choosing wrong usually shows up as “the second screen that opens doesn’t have the data” or “old events keep firing when they shouldn’t.”
4. Threading Surprises
Subjects are not magically thread-safe in the way some people expect. If you call .onNext from multiple threads without care, you can get race conditions.
In practice I usually make sure all emissions happen on a known scheduler (often MainScheduler for UI-related subjects).
5. Over-using Subjects
Subjects are convenient, but they make your code more imperative. Whenever possible, prefer pure Observables that are created from existing sources (network, notifications, UI controls, etc.). Reach for a Subject only when you genuinely need to push values from outside the reactive chain.
A Practical Pattern I Use Often
final class SomeViewModel {
// Private so only the ViewModel can emit
private let _state = BehaviorSubject<ViewState>(value: .loading)
// Public read-only version
var state: Observable<ViewState> {
_state.asObservable()
}
func loadData() {
// ... network call
_state.onNext(.loaded(data))
// or
_state.onNext(.error(error))
}
}
This pattern keeps the mutability private while still giving the UI a clean Observable to bind to.
Final Thoughts
The day I properly understood the difference between PublishSubject, BehaviorSubject, ReplaySubject and AsyncSubject was the day my RxSwift code got noticeably cleaner. I stopped fighting the library and started choosing the right tool for the job.
You don’t need to memorise every edge case. Just remember the core idea:
Publish → pure events
Behavior → current state
Replay → recent history
Async → final result only
Once that clicks, the rest becomes muscle memory.
If you’ve been using mostly PublishSubject for everything (like I did for a long time), try rewriting one of your ViewModels with the more appropriate Subject types. You’ll feel the difference quickly.


Top comments (0)