If you work with Angular, there is no escaping RxJS. Sooner or later, you need to combine two or more observables, open the documentation and find four operators that all seem to do the same thing: zip, combineLatest, withLatestFrom and forkJoin. They do not. The entire difference is when they emit, and choosing the wrong one is the fastest way to get a request that runs twice or a spinner that never stops.
This one-shot guide is a remake of an article I wrote in 2023: the same story, redesigned from top to bottom. This time, all four main characters are on stage.
π¨ The scenario
We need to draw colored icons, and the information comes from two different people: one tells us the color, the other the type of icon. They are not synchronized: they each speak whenever they want, independently. In our code, they are two observables, color$ and icon$.
1import { Subject } from 'rxjs';
2
3type Color = 'red' | 'blue' | 'green' | 'purple';
4type Icon = 'github' | 'bitbucket' | 'gitlab';
5
6// the two people supplying information
7const color$ = new Subject<Color>();
8const icon$ = new Subject<Icon>();
9
10// the order in which they speak
11color$.next('red');
12icon$.next('github');
13
14color$.next('blue');
15icon$.next('bitbucket');
16
17color$.next('green');
18icon$.next('gitlab');
19
20color$.next('purple');Notice the ending: four colors, but only three icons. This small imbalance will reveal the differences between the operators.
π€ zip: one from each, paired up
zip pairs emissions by position: the first color with the first icon, the second with the second, and so on. Just like a zipper.
1import { zip } from 'rxjs';
2
3zip(color$, icon$).subscribe(([color, icon]) =>
4 console.log(`Disegnata icona ${icon} di colore ${color}`)
5);
6
7// β github red
8// β bitbucket blue
9// β gitlab green
10// ("purple" waits for an icon that will never arrive)
Both values must arrive before a new pair can be emitted: the fourth color is left waiting, because there is no fourth icon.
When to use it: when the two streams actually advance in pairs and you need an exact match by index. This is the rarest of the four cases.
The dark side of
zip: if one source is faster than the other, its values accumulate in a buffer while waiting for a partner. With infinite, unbalanced streams, memory usage grows silently.
π combineLatest: react to every change
The big omission from the first version of this article, and ironically the most widely used of the four. combineLatest emits the latest available combination whenever any source speaks.
1import { combineLatest } from 'rxjs';
2
3combineLatest([color$, icon$]).subscribe(([color, icon]) =>
4 console.log(`Disegnata icona ${icon} di colore ${color}`)
5);
6
7// β github red (first complete combination)
8// β github blue (color changes, icon stays)
9// β bitbucket blue (icon changes, color stays)
10// β bitbucket green
11// β gitlab green
12// β gitlab purple
Every update, wherever it comes from, produces a new pair using the other source's "last known value".
When to use it: this is the operator for forms with multiple filters. Search query, page, sort order: when any one changes, recalculate the result using the latest values from all of them.
combineLatest([query$, page$, sort$])
.pipe(switchMap(fetchResults))
.subscribe(render);The classic gotcha:
combineLatestdoes not emit until every source has spoken at least once. If a filter starts out "silent", you get no results and no error. The fix is to give it an initial value, usingstartWithor aBehaviorSubject.
π― withLatestFrom: a lead and a context
Here, the relationship is no longer equal: there is a primary source in charge, and a secondary one that is merely consulted. In our case, the color decides when to draw, while the icon provides context.
1import { withLatestFrom } from 'rxjs/operators';
2
3color$
4 .pipe(withLatestFrom(icon$))
5 .subscribe(([color, icon]) =>
6 console.log(`Disegnata icona ${icon} di colore ${color}`)
7 );
8
9// β github blue (on the second color: the first arrived
10// before icon$ had a value)
11// β bitbucket green
12// β gitlab purple
Only emissions from color$ trigger output; icon$ can speak as much as it likes, but on its own it produces nothing.
When to use it: when an event needs to read some state on the fly. A click using the latest form value, a submit that includes the current configuration: the event leads, the state accompanies it.
Watch the startup here too: until the secondary source has emitted at least one value, emissions from the primary source are discarded. That is why the color "red" disappears without a trace in our example.
π forkJoin: wait until everyone is finished
forkJoin is the most drastic: it ignores everything along the way and emits just once, when all sources have completed, using the last value from each.
1import { forkJoin } from 'rxjs';
2
3forkJoin([color$, icon$]).subscribe(([color, icon]) =>
4 console.log(`Disegnata icona ${icon} di colore ${color}`)
5);
6
7// ...eventually, both people say goodbye and leave
8color$.complete();
9icon$.complete();
10
11// β gitlab purple (one emission: the last values)
When to use it: it is RxJS's Promise.all. A set of HTTP calls whose results you need together before rendering the page: forkJoin waits, collects and delivers everything in one go. (It works perfectly with HttpClient calls, because they complete automatically after the response.)
Here is the infinite spinner promised at the start: if even one source never completes (a Subject left open, an event stream),
forkJoinnever emits anything. No error, no warning: just endless loading. If it has happened to you, now you know the culprit.
π§ Which should I choose? The cheat sheet
- zip: I need pairs by position, first with first, second with second.
- combineLatest: I want to react to every change using everyone's latest values (forms, filters).
- withLatestFrom: I have an event in charge and some state to read along the way.
- forkJoin: I wait until all sources finish and only need the final result (groups of HTTP calls).
β Conclusion
Four operators that appear to "combine observables", and four completely different contracts about when to emit. That is why they look interchangeable in tutorials and then blow up in production: the difference does not show in the happy path, but when streams are unbalanced, silent or infinite. In other words, all the time in the real world.
The next time you need to combine two streams, do not ask "which operator do I know?": ask what should trigger the emission. The answer to that question gives you the right operator.
