skip to content

In RxJS, what risk does concatMap carry when saves arrive faster than the server answers, and how does mergeMap's concurrent argument change it?

level: seniorimportance: should knowfreq 45%

answer

  1. one slot, unbounded queue
  2. latency grows with the backlog
  3. concurrent trades order for throughput
  4. coalesce saves of the same record

basics

~20 s

concatMap runs one save at a time and buffers the rest in an unbounded queue, so a slow server grows the backlog, memory and latency. mergeMap(save, n) runs up to n saves at once, draining faster but losing ordered completion.

solid answer

~50 s

`concatMap(save)` is `mergeMap(save, 1)` in RxJS 7.8: one save runs, every other value waits in mergeMap's buffer, which has **no size limit**. If edits arrive every 200 ms and each save takes a second, the queue grows without bound, the last edit reaches the server long after it was made, and everything queued is lost if the stream is torn down. `mergeMap(save, 3)` allows three saves in flight, draining the queue faster, but responses and server writes may now happen out of order — wrong for successive saves of the **same** document, fine for independent records. For one document the better fix is to send fewer saves: debounce the edits or coalesce to the latest state, and keep `concatMap` for ordering. Avoid `switchMap` for writes: cancelling on the client does not undo a write the server already accepted.

code

ts · 18 lines
ts
import { Observable, debounceTime, concatMap, mergeMap, from } from 'rxjs';

interface Doc { id: string; body: string; }
declare function saveDoc(doc: Doc): Observable<void>;
declare function uploadFile(file: File): Observable<void>;
declare const docChanges$: Observable<Doc>;
declare const files: File[];

// One document: fewer saves, still in order
const saved$ = docChanges$.pipe(
  debounceTime(500),
  concatMap(doc => saveDoc(doc))
);

// Independent files: at most three uploads at a time
const uploaded$ = from(files).pipe(
  mergeMap(file => uploadFile(file), 3)
);

go deeper

for a junior

Recall that concatMap runs one inner at a time and queues the rest in order.

for a middle

Explain concatMap as mergeMap with concurrency one, the unbounded buffer, and what the concurrent argument trades away.

for a senior

Diagnose a growing save backlog and fix it by reducing writes upstream, keeping order where it matters and bounding concurrency where it does not.

for a principal

Relate client-side flattening choices to server idempotency, versioning and conflict handling so save semantics are consistent.

## concatMap is a queue In RxJS 7.8, `concatMap(project)` is literally `mergeMap(project, 1)`. The shared implementation keeps: - an **active** counter, capped at the `concurrent` value; - a **buffer** array holding source values that arrived while every slot was busy. When an inner completes, the next buffered value is shifted off and subscribed. The buffer has **no capacity limit** and no drop policy. That is what makes `concatMap` safe for ordering and risky for throughput. ## When saves outpace the server Consider an editor that saves each change: | time | edits produced | saves completed | waiting in the buffer (about) | |---|---|---|---| | 0–1 s | 5 | 1 | 4 | | 1–2 s | 5 | 1 | 8 | | 2–3 s | 5 | 1 | 12 | The consequences compound: 1. **Latency** — the newest edit waits behind the whole backlog; the server state lags further and further behind the screen. 2. **Memory** — every queued value (a full document, perhaps) is retained until processed. 3. **Loss on teardown** — unsubscribing discards the buffer; everything still queued never reaches the server. 4. **Wasted work** — most queued saves write intermediate states that are overwritten moments later. ## What mergeMap's concurrent argument changes `mergeMap(project, concurrent)` raises the number of slots: | call | in flight | ordering of completions | buffer | |---|---|---|---| | `concatMap(save)` / `mergeMap(save, 1)` | 1 | strictly source order | unbounded | | `mergeMap(save, 3)` | up to 3 | whichever finishes first | unbounded, drains faster | | `mergeMap(save)` | unlimited (`Infinity`) | whichever finishes first | never used | Concurrency drains the queue faster, but for **the same record** it reintroduces a race: save #4 might be written after save #5, leaving the older state on the server. It is the right tool for **independent** items — uploading ten files, saving rows that do not depend on each other — with a limit that protects the server and the browser's connection pool. ## Better fixes for one document For successive saves of one document, the real problem is sending too many: - **Debounce** the edits (`debounceTime`) so a save starts only after a pause. - **Coalesce** — save the latest state rather than each delta, so a backlog collapses to one request. - Keep **`concatMap`** after that, so the remaining saves still apply in order. ## Why not switchMap for writes `switchMap` would cancel the in-flight save whenever a new edit arrives. Cancelling unsubscribes on the client — with Angular's `HttpClient` it aborts the request — but the server may already have received and applied it. The client then believes the write never happened, and the final state depends on timing. Reads can be cancelled safely; writes generally cannot. ## exhaustMap is not an option either `exhaustMap` would drop edits made while a save is running, so the last changes may never be saved at all. ## Spotting it in a running app A growing `concatMap` backlog has recognisable symptoms: - the "saved" indicator trails further behind the user's edits the longer they type; - network logs show saves firing back-to-back long after typing stopped; - memory grows during long editing sessions and drops when the editor closes. Counting values with a `tap` before `concatMap` and inside the inner's completion gives the queue length directly: the difference between values that entered and saves that finished. ## A review checklist - Can the source emit faster than the inner completes? If so, `concatMap`'s buffer will grow. - Do the writes target the same record? If so, order matters; do not raise concurrency. - Can the number of writes be reduced upstream by debouncing or coalescing? - What happens to queued work on teardown, and is that acceptable?

  • What is mergeMap's default concurrency in RxJS 7.8?
    `Infinity`. Every source value is projected and subscribed immediately, so the buffer is never used; pass a number as the second argument to cap how many inner observables run at once.
  • What happens to values queued inside concatMap when the subscriber unsubscribes?
    They are discarded with the rest of the operator's state. Only the active inner has started; everything still in the buffer never reaches the server.

saying these in an interview costs you the question

  • concatMap drops saves when the server is slow.
  • concatMap has a bounded queue that applies backpressure to the source.
  • mergeMap with a concurrent limit keeps completions in source order.
  • switchMap is safe for saves because cancelled requests are never applied.
  • Raising concurrency is the best fix for repeated saves of one document.