skip to content

In RxJS, what is the difference between the map and tap operators, and why do side effects belong in tap?

level: juniorimportance: must knowfreq 70%

answer

  1. one returns a new value
  2. the other ignores its return value
  3. passes the same value through
  4. subscribe, unsubscribe and finalize handlers

basics

~20 s

map replaces each value with its projection's result; tap runs a callback for each notification and passes the original value through, ignoring the callback's return. Side effects in tap keep map pure and visible in the pipe.

solid answer

~40 s

`map(project)` calls `project(value, index)` for every value and emits the **result**, so it changes what flows downstream. `tap(...)` calls your callback and then emits the **same** value, discarding whatever the callback returns - it exists for side effects such as logging, analytics or setting a loading flag. `tap` also accepts an observer object with `next`, `error` and `complete`, and since RxJS 7.3 `subscribe`, `unsubscribe` and `finalize` handlers. Keeping effects in `tap` means `map` stays a pure transformation that is safe to reorder or test, and a reader can see at a glance where the pipe touches the outside world. Neither operator runs until something subscribes, and an exception thrown in either becomes an `error` notification downstream.

code

ts · 20 lines
ts
import { of, map, tap } from 'rxjs';

const doubled$ = of(10, 20).pipe(
  tap({
    subscribe: () => console.log('subscribed'),
    next: (v) => console.log('raw', v),
    finalize: () => console.log('finalized'),
  }),
  map((v) => v * 2),
);

// nothing is logged yet: no subscriber

doubled$.subscribe((v) => console.log('doubled', v));
// subscribed
// raw 10
// doubled 20
// raw 20
// doubled 40
// finalized

go deeper

for a junior

Recall that map emits a transformed value while tap runs a side effect and passes the original value through unchanged.

for a middle

Explain tap's observer form, including error, complete and the 7.3 subscribe, unsubscribe and finalize handlers, and why a map without a return emits undefined.

for a senior

Use tap for lifecycle effects such as loading flags via finalize, and spot effects hidden in map that break when operators are reordered or pipes are shared.

for a principal

Set a team convention that transformations stay pure and every external effect is visible in tap or in the subscriber, so pipes can be reviewed and tested predictably.

## Two operators, two purposes RxJS pipeable operators are functions passed to `.pipe()`. Two of the most common look similar - both take a callback that runs for every value - but they do different jobs. | | `map(project)` | `tap(observerOrNext)` | |---|---|---| | Emits | the value returned by `project` | the original value, unchanged | | Callback's return value | becomes the new value | ignored | | Callback arguments | `(value, index)` | `(value)` for `next` | | Sees `error` / `complete` | no | yes, with an observer object | | Intended for | pure transformation | side effects | ## map: transform `map(project)` emits `project(value, index)` for each source value, where `index` counts from `0` per subscription. Errors and completion pass straight through. If `project` throws, RxJS catches the exception and sends it downstream as an `error` notification, ending the stream. A frequent bug is using `map` for an effect and forgetting to return: `map(v => { console.log(v); })` emits `undefined` for every value. The TypeScript type reveals it - the stream becomes `Observable<void>` - but only if someone reads it. The old `mapTo(value)` operator is deprecated in RxJS 7 and marked for removal in v9; write `map(() => value)` instead. ## tap: observe without changing `tap` accepts either a single function, used as `next`, or a **`TapObserver`** object: - `next(value)` - for each value; - `error(err)` - when the source errors, before the error continues downstream; - `complete()` - when the source completes; - `subscribe()` - when the tapped stream is subscribed (added in RxJS 7.3); - `unsubscribe()` - when the consumer unsubscribes before the source ended (7.3); - `finalize()` - on any ending: error, complete or unsubscribe (7.3). Whatever the callbacks return is discarded, and the original notification continues. Passing separate `next, error, complete` function arguments, as in `tap(fn, errFn)`, still works but is deprecated in favour of the observer object. ## Why effects go in tap 1. **Readability.** A reviewer scanning a pipe can find every place it touches the outside world by looking for `tap`. 2. **Correctness of `map`.** A `map` that also writes state is easy to break by reordering operators or by copying it into another pipe. 3. **Coverage of all notifications.** Effects that must react to errors or completion - hiding a spinner, recording a failure - need `tap`'s observer form; `map` never sees those. 4. **No accidental value change.** Because `tap` ignores its return value, an effect cannot silently turn the stream into `undefined`. ## Things tap does not do - It does **not** subscribe. A pipe ending in `tap(console.log)` logs nothing until someone subscribes; this is a common "why is my tap not firing" question. - It does **not** run once per stream when there are several subscribers. Each subscription runs the whole pipe, so the effect runs once per subscriber. - It is **not** error handling. An exception thrown inside `tap` becomes an error notification like any other; recovering from errors is done with RxJS's error operators, a separate topic. ## A practical pattern A loading indicator is a typical `tap` use: set `loading = true` in the `subscribe` handler, and set it back to `false` in `finalize`, which fires whether the request completes, errors or is cancelled. Before 7.3 the same effect needed a separate `finalize` operator; both approaches are valid in RxJS 7.8. ## Placement inside the pipe Where `tap` sits decides what it sees. In `source$.pipe(tap(log), map(f))` it logs raw values; in `source$.pipe(map(f), tap(log))` it logs mapped values. For debugging, a tap at each stage shows how a value changes as it moves through the pipe, and a small named helper - a function returning `tap` with a label - keeps those probes easy to find and remove. Removing a `tap` never changes the values downstream, which is exactly why it is safe to add one temporarily. ## What interviewers want to hear "`map` changes the value, `tap` looks at it." Then: the return value of `tap` is ignored, `tap` can observe errors and completion, it does not subscribe on its own, and side effects belong there so that transformations stay pure.

  • In RxJS, what does source$.pipe(map(v => { console.log(v); })) emit?
    It emits `undefined` for every source value, because the arrow function has a block body with no `return`, and `map` emits whatever the projection returns. The logging still happens. Either move the logging into `tap(v => console.log(v))` or return the value explicitly; TypeScript shows the problem as an `Observable<void>`.
  • In RxJS 7.3 and later, how does tap's finalize handler differ from its complete handler?
    `complete` runs only when the source completes. `finalize` runs on every ending - completion, error, or the consumer unsubscribing - after the subscription has closed. That makes `finalize` the right place for cleanup such as clearing a loading flag, where `complete` would miss both cancellation and failure.

map is a workstation on an assembly line that swaps each part for a finished one; tap is an inspector with a clipboard who looks at each part, writes something down, and puts the same part back on the belt.

saying these in an interview costs you the question

  • tap can change the value by returning a new one.
  • A pipe with tap(console.log) logs values even if nothing subscribes.
  • map and tap are interchangeable as long as you return the value.
  • tap only sees next values, never errors or completion.
  • A tap in a pipe runs once no matter how many subscribers there are.