RxJS operators are fundamental when dealing with complex asynchronous workflows, especially when handling streams and managing side effects in applications.
Flattening operators manage how multiple inner Observables are subscribed to and how their emitted values are handled.
In this article, we’ll explore four commonly used flattening operators: mergeMap, switchMap, concatMap, and exhaustMap.
We’ll focus on their differences describing them with practical examples involving HTTP GET requests.
We have already used a flattening operator in previous lessons to handle sequences of http requests: mergeMap,
First, we start with two definitions, trying to simplify the concept as much as possible:
Outer Observable: this is the main observable that you subscribe to.
Inner Observable: this is the observable that gets subscribed to after each emission from the outer observable.
Flattening operators manage how the inner Observables are subscribed to and combined with the outer Observable to produce a single stream of emissions.
The key responsibility of flattening operators is to determine:
1
When to subscribe to new inner Observables.
2
How to handle multiple inner Observable emissions.
3
What happens to inner Observables when a new one is emitted.
Use cases
We’ve already seen a similar case in previous lessons where we used mergeMap to make nested calls, but potentially the outer observable can emit values from an input field, a button, a timer or anything else:
So the outer observable can be created by using any RxJS creation operator:
The problem flattening operators solve is the management of inner observable subscriptions.
For example, what happens if the outer observable emits many values?
How are the subscriptions to inner observables managed?
The marble diagram above describe a similar scenario:
we want to invoke a REST API (inner observable) each time a value is typed into an input field (emitted by the outer observable)
since the user could type very quickly there could be many HTTP calls that start almost simultaneously.
the problem is that no one can guarantee that the result will arrive in the same order in which they were invoked. So, for instance, we might get the result of the first letter typed last
SOLUTION: this scenario will be resolved by the operator concatMap, described later in the page.
What about debounce?
The most experienced would say that to solve the problem it would be enough to apply a debounce (e.g. with debounceTime). Right! Since the purpose of this article is to explain flattening operators I won't use it now but you can find an example at the end of the article.
Another scenario: login
Another interesting scenario in which flatterning operators can be exploited is as follows:
the user clicks on a button to login invoking a REST API
however, the user may unintentionally click 2 or 3 times quickly, making too many unnecessary server calls.
Would it not be sufficient to make the first call while ignoring the subsequent ones? Of course it would!
SOLUTION: This scenario, as you will see in the following paragraphs, will be addressed using the exhaustMap operator.
The following diagram shows how the previous script works.
The first HTTP request will be done while the others will be completely ignored:
You want to handle the case where users click the button multiple times very quickly. In this case:
1
The first HTTP call will be performed
2
The second call will likely begin while the previous one is still in progress.
3
This pattern continues with subsequent clicks, so for the third one and so on ...
Depending on the (flattening) operator you use, the behavior of handling multiple clicks will differ.These operators allow us to decide whether calls should be handled in a queue, made in parallel and so on.
This approach is not limited to HTTP calls only but can be used in many contexts where we have an outer observable that emits values and inner observables that are subscribed at each emission.
The mergeMap operator subscribes to every inner observable and allows multiple requests to run concurrently.
In the following example:
the outer observable is generated by clicking a button, using the fromEvent operator
when the button is clicked we invoke the REST API, using ajax.getJSON
index.ts
index.html
index.ts
index.html
Result
Each time the button is clicked, it fires off an HTTP request receiving the user object with id = 1, even if a previous request is still pending.
All responses will be processed as they come in and the order in which results arrive is not important
Use mergeMap when you want all events to be processed, regardless of when they happen.
Simulate the problem
However, the above example does not actually allow to understand the real problem.
The reason is that we always invoke the same endpoint, so we do not notice if the order is being followed or not.
So we modify the example slightly and instead of emitting the values by clicking the button, we simulate a scenario in which ten values, from 1 to 10, are emitted extremely quickly using the of() operator.
So when we invoke the API we build the URL using the value received from the outer observable: /API/[id]
index.ts
index.html
index.ts
index.html
Result
As you can see from the image below, even if the outer observable emits values from 1 to 10, and HTTP calls are made in the same order, the result is absolutely random.
In fact, every time the snippet runs we will receive the results in a different order:
The concatMap operator queues each request and waits for the previous one to complete before starting the next.
No requests are canceled or ignored, but they are executed in sequence.
Below is the same example that now uses the concatMap operator:
index.ts
Result
Each emission triggers an HTTP request, but only one request runs at a time.
In short, the inner observable is not subscribed until the previous inner observable has been completed.
So if we have 10 HTTP calls that are invoked almost simultaneously, the last call (10th) will necessarily have to wait for all 9 previous calls to complete.
concatMap is useful when requests need to be processed in order
The switchMap operator cancels any previous inner observable when a new one arrives.
This is especially useful when you only care about the latest event (e.g., autocomplete suggestions).
index.ts
Result
Since the requests start almost simultaneously, each time a request is done, the previous one is canceled, even if it has not yet been completed.
So all 9 requests will be canceled, except the last:
switchMap can be useful when we only need the last result, for instance an auto-suggest input field or a search text where user types several character very quickly
# exhaustMap: Ignore Subsequent Requests While One Is Active
The exhaustMap operator ignores any new inner observables if one is already active.
It allows only one request at a time and ignores all further requests until the current one completes.
index.ts
Result
The first request is made.
The next 9 requests will be ignored, as the first one is still in progress
exhaustMap can be used to avoid compulsive clicks. For example an user that clicks several time on a login button but we only want to run the first HTTP request, ignoring the others.
In the following example we write the same use case with Angular and Reactive Forms.
The valueChanges property of the input field is an observable that emits values every time something is typed
After each character typed, a call to the server will be made using the HttpClient Angular service
Using the switchMap operator we're sure to receive the response of the last typed character because the previous requests are always canceled (unsubscribed)
To see the differences between operators you should write very quickly into the text field.
Try it searching the suggested usernames and using different flattening operators:
app.component.ts
main.ts
src/app.component.ts
src/main.ts
Differences
If you try the other operators in the above example, you may notice that:
mergeMap: may return the correct result but if you type very fast in the input field you might not receive the result of the last call
concatMap: create a queue and all calls will be made one after the other, in order. So we will definitely get the last result. That's correct. Despite this, all HTTP requests will be made unnecessarily until they are completed and the result is that you will generate more traffic (and the final response is also slower to receive)
exhaustMap: if we type quickly, the risk is that the second or third call will be ignored because the previous one is still in progress. So we risk getting the first result, after the first letter typed, and not the last one.
The switchMap operator assures us of getting the last result instead, unsubscribing the current http call (if running) when user types something into the input field.
So it's the best solution for this scenario.
However, all these problems can be avoided by using the debounceTime operator.
This operator helps reduce the number of events emitted by the outer observable by waiting for a specific period of inactivity before passing the latest value down the pipeline.
This can be especially useful when combined with flattening operators to create a much more efficient and responsive system.
In the following snippet, the debounceTime(700) operator solves the problem by waiting for a pause in the user’s typing.
Specifically, it waits for 700ms of inactivity before emitting the latest value.
If the user keeps typing within that 700ms window, the stream cancels the previous value.
This ensures that we don’t send unnecessary requests for every keystroke.
Only after the user has paused typing for 700ms does it trigger a request.
As a result, the choice of which flattening operator to use is almost irrelevant, since we will never have overlapping calls.
In this article most problems can be solved with debounceTime, so it's definitely the best approach.
However the goal of the article is to understand how flattening operators work so it would not have made sense to use it from the beginning :)