I use RxJS action streams to trigger API calls and often find myself hesitating before choosing an operator. The documentation covers syntax, but the practical decision of which flattening operator fits a given scenario can feel arbitrary. The common advice—"just use `switchMap` for GET and `concatMap` for POST"—is a shortcut that often misses the point. The true criterion is what a repeated action means in your application's context. Consider block
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';

export const loadTodos = createEffect(
  (
    actions$ = inject(Actions),
    todoService = inject(TodoService),
  ) =>
    actions$.pipe(
      ofType(TodosPageActions.load),
      switchMap(() =>
        todoService.getAll().pipe(
          map((todos) => TodosApiActions.loadSuccess({ todos })),
          catchError((error: unknown) =>
            of(TodosApiActions.loadFailure({ error })),
          ),
        ),
      ),
    ),
  { functional: true },
);
. While the first examples use NgRx Effects for clarity, the underlying principles apply to any RxJS-based action handler, including `rxMethod` in a SignalStore or a ComponentStore effect. Let's look at the mechanism behind these operators before diving into decision criteria. ## The core problem: flattening nested Observables Every action stream is an Observable, and when an action triggers a service call, that service returns another Observable. So, we are working with a higher-order Observable—an Observable that emits other Observables. The four operators in question—`mergeMap`, `switchMap`, `concatMap`, and `exhaustMap`—all handle this nesting, but they differ in how they treat an inner Observable when a new action arrives while the previous one is still active. The table in
Operator When another action arrives Useful mental model
mergeMap Subscribe to it too Run in parallel
concatMap Put it in a queue One at a time, in order
switchMap Unsubscribe from the previous inner Observable and switch to the new one Latest subscription wins
exhaustMap Ignore the new action while the current inner Observable is active First one wins until completion
summarizes this behavior. The last column of that table—describing what happens to the previous or new action—is more informative than any rule of thumb based on the HTTP verb. ### `mergeMap`: run everything in parallel With `mergeMap`, every inner Observable is subscribed to immediately upon arrival. Operations run concurrently, and responses can come back out of order. This setup works best when each action is independent and every action represents work that must be completed. The main hazard is a race condition: if two operations update the same piece of state, an older response arriving last could overwrite newer data. ### `concatMap`: queue everything in order `concatMap` enforces a strict sequence. It will not subscribe to the next inner Observable until the current one has emitted a complete notification. This operator does not drop any actions and starts them in order. However, the cost is latency—a single slow operation will hold up everything that follows it. This is a solid default for write operations that must be applied sequentially. ### `switchMap`: keep listening only to the latest When a fresh action arrives, `switchMap` unsubscribes from the previous inner Observable and subscribes to the new one. With Angular's `HttpClient`, unsubscribing typically cancels the client-side request. But do not mistake this for a guarantee that the server never processed it. A write operation may have already crossed the network boundary. This makes `switchMap` a strong choice for replaceable reads—search-as-you-type inputs are a textbook case—but a risky one when applied broadly to writes. ### `exhaustMap`: ignore repeated triggers `exhaustMap` subscribes to the first inner Observable it encounters and then completely ignores all subsequent actions until that inner Observable completes. Its strength lies in filtering out accidental or meaningless re-triggers: double-clicks on a login button, duplicate form submissions, or repeated requests to delete the same resource while that request is already in flight. One critical reminder: ignored actions are not stored or replayed later—they are simply lost. ## Do not choose by HTTP verb alone Saying "use `switchMap` for GET" is a convenient shorthand, but it misses the nuance that the right operator depends on your state management strategy. Instead, ask yourself these questions when a new action fires: - Does every action represent work that absolutely must occur? - Can two of these operations safely run concurrently? - Does the order of completion matter for the final state? - Does a newer action semantically replace an older one? - Should repeated actions be suppressed while a request is already in progress? - Are these answers consistent across all actions, or do they differ depending on the specific entity involved? The last question is where the choice becomes truly context-dependent. ## Case study: loading a single replaceable collection Imagine an app with a single, visible list of todos in the store. Each `load` action implies the same thing: "Redefine the list with the data from this Http call." In this scenario, `switchMap` is the natural operator (see
actions$.pipe(
  ofType(TodosPageActions.load),
  switchMap(() =>
    todoService.getAll().pipe(
      map((todos) => TodosApiActions.loadSuccess({ todos })),
      catchError((error: unknown) =>
        of(TodosApiActions.loadFailure({ error })),
      ),
    ),
  ),
);
). If the user refreshes twice, the chances are they care about the most recent response as the source of truth, not the first one that happens to return. `exhaustMap` could also work here, but it encodes a different product decision. Where `switchMap` says "cancel the old and start the new," `exhaustMap` says "pretend the new click never happened while we are busy." The NgRx guide to effects uses this pattern in its own collection-loading example. Neither is universally correct; your user experience design decides. ## Case study: loading a collection with query parameters Now consider a scenario with query parameters (
TodosPageActions.load({ completed: true });
TodosPageActions.load({ completed: false });
). Should a new query cancel the request for the previous one? It depends wholly on how you store the state. If the store's shape is simply "the currently selected list," then the second action logically replaces the first query. There is no reason to show stale data from the old filter. `switchMap` remains the appropriate choice (
switchMap(({ completed }) =>
  todoService.getAll({ completed }).pipe(
    map((todos) =>
      TodosApiActions.loadSuccess({ completed, todos }),
    ),
    catchError((error: unknown) =>
      of(TodosApiActions.loadFailure({ completed, error })),
    ),
  ),
)
). Conversely, if the store caches results per query string—maintaining a separate entry for each filter—then both requests are meaningful. Cancelling the first would leave that cache entry perpetually empty. In this case, you want `mergeMap`, as shown in
mergeMap(({ completed }) =>
  todoService.getAll({ completed }).pipe(
    map((todos) =>
      TodosApiActions.loadSuccess({ completed, todos }),
    ),
    catchError((error: unknown) =>
      of(TodosApiActions.loadFailure({ completed, error })),
    ),
  ),
)
. Notice that the success action in the `mergeMap` example contains the query itself. Without that payload, the reducer would not know which cache entry to update upon response. The operator choice was not determined by the presence of a query parameter, but by the storage model. The meaning of a repeated action is shaped by the structure of your state.

Fetching data for individual items

Take a look at these stream actions:

TodosPageActions.loadOne({ id: 1 });
TodosPageActions.loadOne({ id: 2 });
Enter fullscreen mode Exit fullscreen mode

Since these requests target separate entities, aborting one because another appeared would feel wrong. mergeMap lets both proceed simultaneously:

mergeMap(({ id }) =>
  todoService.get(id).pipe(
    map((todo) => TodosApiActions.loadOneSuccess({ todo })),
    catchError((error: unknown) =>
      of(TodosApiActions.loadOneFailure({ id, error })),
    ),
  ),
)
Enter fullscreen mode Exit fullscreen mode

Now consider this scenario:

TodosPageActions.loadOne({ id: 1 });
TodosPageActions.loadOne({ id: 1 });
Enter fullscreen mode Exit fullscreen mode

Here the requests are for the same entity. Running them concurrently means an older response could land after a newer one.

The behaviour we actually need:

  • different IDs: allow concurrent execution;
  • the same ID: subscribe only to the most recent request.

This pattern is called keyed concurrency.

Implementing keyed concurrency using groupBy

The groupBy operator in RxJS can partition the action stream into separate streams for each ID. Applying switchMap within each partition and mergeMap across partitions gives us the desired effect:

actions$.pipe(
  ofType(TodosPageActions.loadOne),
  groupBy(({ id }) => id),
  mergeMap((actionsForId$) =>
    actionsForId$.pipe(
      switchMap(({ id }) =>
        todoService.get(id).pipe(
          map((todo) =>
            TodosApiActions.loadOneSuccess({ todo }),
          ),
          catchError((error: unknown) =>
            of(TodosApiActions.loadOneFailure({ id, error })),
          ),
        ),
      ),
    ),
  ),
);
Enter fullscreen mode Exit fullscreen mode

This accomplishes both requirements:

  • requests for distinct entities can overlap;
  • a newer request for a shared entity cancels its predecessor.

When the discriminator is an object with multiple fields, derive a reliable key from the fields that define identity. Avoid relying on JSON.stringify unless you control property ordering and formatting precisely.

Caution about groupBy group lifetime

Action streams in an application typically stay subscribed for a long time. If no duration is provided, groupBy keeps every distinct group open as long as the subscription lives. With a small, predictable number of keys this is fine—but a steady flow of unique IDs will accumulate groups without limit.

RxJS allows a duration to close idle groups:

groupBy(
  ({ id }) => id,
  {
    duration: (group$) => group$.pipe(debounceTime(30_000)),
  },
)
Enter fullscreen mode Exit fullscreen mode

This warrants more thought than it typically receives. If a group shuts down while an inner request is underway, a later action with the same key spawns a new group. The in-flight request and the fresh group can operate concurrently, breaking the per-key isolation.

Only use a duration when its expiry aligns with your domain logic, or where occasional overlap is tolerable. For rigorous, reusable keyed concurrency, prefer a battle-tested custom operator or a coordination layer that explicitly tracks request lifecycles. Copying a five-second timeout from a blog post does not constitute a correctness guarantee—I say this with fondness, having once wished it did.

Creating entities

For creation, each independent action generally needs to be honoured. mergeMap allows them to execute concurrently:

actions$.pipe(
  ofType(TodosPageActions.add),
  mergeMap(({ draft }) =>
    todoService.add(draft).pipe(
      map((todo) => TodosApiActions.addSuccess({ todo })),
      catchError((error: unknown) =>
        of(TodosApiActions.addFailure({ draft, error })),
      ),
    ),
  ),
);
Enter fullscreen mode Exit fullscreen mode

Switch to concatMap when creation order matters or the backend is not concurrency-safe.

Use exhaustMap when the UI represents a one-shot submission and repeat submissions must be disregarded. Disabling the submit control while a request is active is still recommended UX, but the effect adds a safety net.

switchMap is rarely a sound default for creation. Aborting a subscription cannot retract a record the server has already stored.

Updating entities

Here I would challenge a widely repeated recommendation: "latest wins" at the client is insufficient for writes that target the same entity.

Suppose two edits occur:

TodosPageActions.update({ id: 1, changes: { title: 'First' } });
TodosPageActions.update({ id: 1, changes: { title: 'Second' } });
Enter fullscreen mode Exit fullscreen mode

With switchMap, the first response might be dropped, but the server could still apply the first update after the second. The client appears current until a refresh reveals stale state.

A more robust client-side approach:

  • different IDs: apply updates concurrently;
  • the same ID: serialize updates in submission order.
actions$.pipe(
  ofType(TodosPageActions.update),
  groupBy(({ id }) => id),
  mergeMap((actionsForId$) =>
    actionsForId$.pipe(
      concatMap(({ id, changes }) =>
        todoService.update(id, changes).pipe(
          map((todo) =>
            TodosApiActions.updateSuccess({ todo }),
          ),
          catchError((error: unknown) =>
            of(TodosApiActions.updateFailure({ id, error })),
          ),
        ),
      ),
    ),
  ),
);
Enter fullscreen mode Exit fullscreen mode

This keeps the order in which the client dispatches each entity's updates. It does not address every distributed-system concern: other clients may still write simultaneously. Where that is a risk, rely on server-side mechanisms like optimistic concurrency, ETags, or version checks.

For frequent UI changes, another legitimate strategy is debouncing before issuing writes, or separating a local "draft" action from an explicit "commit" action. That is a product and data-integrity decision, beyond simply picking an operator.

Deleting entities

When deleting, repeated attempts on the same ID are generally redundant, but deletions on different IDs may safely run in parallel.

This points to per-key exhaustMap:

actions$.pipe(
  ofType(TodosPageActions.delete),
  groupBy(({ id }) => id),
  mergeMap((actionsForId$) =>
    actionsForId$.pipe(
      exhaustMap(({ id }) =>
        todoService.delete(id).pipe(
          map(() =>
            TodosApiActions.deleteSuccess({ id }),
          ),
          catchError((error: unknown) =>
            of(TodosApiActions.deleteFailure({ id, error })),
          ),
        ),
      ),
    ),
  ),
);
Enter fullscreen mode Exit fullscreen mode

A global exhaustMap would also block deleting ID 2 while ID 1 is being deleted. That fits a single confirmation dialog, but it is needlessly restrictive for many list-based UIs.

Also consider making deletion idempotent at the API level. Operator choices shape the UX; server-side semantics safeguard the system.

A quick reference guide

Treat these as departure points, not absolute rules:

Situation Likely choice Why
Latest search or filter replaces the previous one switchMap Old results are no longer useful
Refresh clicks should be ignored while loading exhaustMap Prevent duplicate in-flight loads
Independent reads or creates mergeMap All operations matter and may run together
Ordered writes concatMap Preserve client-side execution order
Latest read per entity groupBy + switchMap Replace only requests with the same key
Ordered writes per entity groupBy + concatMap Serialize the same entity, parallelise different ones
Ignore duplicate work per entity groupBy + exhaustMap Suppress repeats only for the same key

When in doubt, state the required behaviour in plain terms first:

Run all of them, queue them, replace the previous one, or ignore the new one?

Then determine whether that rule holds for the whole action stream or separately for each entity.

Is a custom operator worth it?

Wrapping logic in a bespoke operator cuts down on repetition, yet it also tucks away the essence of the effect: its concurrency behavior.

Labels like PARALLEL, QUEUE, LATEST, and IGNORE_WHILE_BUSY read well, but a reusable utility must also nail down error propagation, type safety, key lifecycle, cancellation, and per-key cleanup. That is no small promise.

Right now, my preferred strategy is:

  • keep the stock RxJS operator in plain sight for straightforward effects;
  • pull out a keyed helper only after the same pattern shows up more than once;
  • choose a name that reflects what it does behaviorally;
  • verify it with marble tests covering both shared and distinct keys;
  • spell out what happens when groups lapse and requests exceed their lifetime.

An abstraction should clarify the concurrency policy, not just shorten the pipeline.

Closing reflection

The right flattening operator does not hinge on GET, POST, PATCH, or DELETE, nor on whether you are using NgRx Effects, SignalStore, or a hand-rolled action stream. It depends on which actions carry weight, which ones can collide, and what your state treats as "the same unit of work."

Ask those questions, and the four operators shift from mere RxJS arcana to four distinct product choices. That is far simpler to think through.