skip to content

In RxJS, a view tears down with takeUntil(destroy$), yet its polling keeps firing after it closes; why does takeUntil's position in the pipe matter?

level: seniorimportance: must knowfreq 55%

answer

  1. completion flows down, not up
  2. flattening waits for its inners
  3. what sits after takeUntil
  4. last operator before subscribe

basics

~20 s

RxJS takeUntil completes only what is downstream of it and unsubscribes only its upstream. A flattening or combining operator placed after it waits for its still-active inner or other sources, so polling survives; put takeUntil last.

solid answer

~40 s

When `destroy$` emits, `takeUntil` unsubscribes from its **upstream** and sends `complete` **downstream**. Operators after it therefore see a completion, not an unsubscription. `switchMap`, `mergeMap`, `concatMap` and `exhaustMap` complete only when the outer source **and** every active inner have completed, so an inner `timer(0, 5000)` polling stream keeps running forever; joins such as `combineLatestWith` likewise wait for their other sources. The fix is to make `takeUntil(destroy$)` the **last** operator before `subscribe()`, so its unsubscription travels up through the flattening operator and tears down the inner stream too. To confirm the diagnosis, add `finalize(() => console.log('poll ended'))` inside the inner stream and check whether it logs when the view closes. Keeping the returned `Subscription` and calling `unsubscribe()` avoids the trap entirely, because unsubscription always propagates up the whole chain.

code

ts · 25 lines
ts
import { Subject, finalize, switchMap, takeUntil, timer } from 'rxjs';

const destroy$ = new Subject<void>();
const refresh$ = timer(0);

// Leaks: takeUntil completes switchMap's outer, but the inner keeps polling.
refresh$
  .pipe(
    takeUntil(destroy$),
    switchMap(() => timer(0, 5000).pipe(finalize(() => console.log('poll ended')))),
  )
  .subscribe((n) => console.log('poll', n));

// Correct: takeUntil last, its unsubscription reaches the inner timer.
refresh$
  .pipe(
    switchMap(() => timer(0, 5000).pipe(finalize(() => console.log('poll ended')))),
    takeUntil(destroy$),
  )
  .subscribe((n) => console.log('poll', n));

setTimeout(() => {
  destroy$.next(); // only the second pipeline logs 'poll ended'
  destroy$.complete();
}, 12000);

go deeper

for a junior

Recall the rule of thumb: takeUntil goes last in the pipe, right before subscribe.

for a middle

Explain that takeUntil unsubscribes upstream but only completes downstream, and why a flattening operator after it keeps its inner running.

for a senior

Diagnose the leak with finalize and repeated open-close cycles, fix placement across a codebase, and recognise joins such as combineLatestWith as the same trap.

for a principal

Decide whether to enforce placement with lint rules or move owners to explicit handles or framework teardown, weighing consistency against migration cost.

## The symptom A dashboard view refreshes its data every five seconds. It uses the classic pattern: a `destroy$` subject fired when the view is torn down, and `takeUntil(destroy$)` in the pipe. After the view closes, the network panel still shows a request every five seconds, and memory grows each time the view is opened and closed. ```ts import { Subject, switchMap, takeUntil, timer } from 'rxjs'; refresh$.pipe( takeUntil(this.destroy$), // too early switchMap(() => timer(0, 5000).pipe(switchMap(() => load()))), ).subscribe(render); ``` ## Two signals, two directions The bug comes from how RxJS operators pass signals along a chain: - **Unsubscription travels upstream.** Calling `unsubscribe()` on the final `Subscription` walks up through every operator, and each one tears down whatever it subscribed to, **including active inner subscriptions**. - **Completion travels downstream.** A `complete` notification tells each later operator "your source is finished", and each operator decides what that means for itself. `takeUntil` does both, from its own position: when the notifier emits, it unsubscribes from the operators **above** it and sends `complete` to the operators **below** it. ## Why the flattening operator keeps the inner alive The higher-order mapping operators define completion as "the outer source has completed **and** no inner subscription is still active": | Operator after `takeUntil` | What it does on outer `complete` | |---|---| | `switchMap` | keeps the current inner running until it completes | | `mergeMap` | keeps every active inner running | | `concatMap` | finishes the active inner and any queued ones | | `exhaustMap` | keeps the current inner running | | `combineLatestWith(other$)`, `mergeWith(other$)` | wait for `other$` to complete too | An inner `timer(0, 5000)` never completes, so the flattening operator never completes, never unsubscribes it, and the observer at the end of the chain stays referenced. The leak is the inner stream plus everything its closures capture. ## The fix 1. **Move `takeUntil` to the end**, directly before `subscribe()`. Now, when `destroy$` emits, `takeUntil` unsubscribes from `switchMap`, and that unsubscription travels up into the inner `timer`, clearing it. 2. **Or keep the handle.** `const sub = stream$.subscribe(...)` followed by `sub.unsubscribe()` on teardown has no placement rule at all, because unsubscription always propagates up the entire chain. 3. **Make sure the notifier emits.** The pattern relies on calling `destroy$.next()` in the owner's teardown; the exact rules for a notifier that completes without emitting belong to `takeUntil`'s own semantics. ```ts refresh$.pipe( switchMap(() => timer(0, 5000).pipe(switchMap(() => load()))), takeUntil(this.destroy$), // last: tears down the inner too ).subscribe(render); ``` ## Why it survives code review The leaking pipe reads naturally from top to bottom: "stop when destroyed, then poll". Nothing fails, no error is logged, and the visible screen behaves correctly because the leaked inner renders into a view that is no longer attached. The cost only appears as duplicated background requests, rising memory, and callbacks running against destroyed state, sometimes much later, when a leaked callback writes into a service that another screen reads. ## Diagnosing it in a real app - Put **`finalize(() => console.log('poll ended'))`** inside the inner stream. If the message never appears when the view closes, the inner was never unsubscribed. - Open and close the view several times while watching the network panel: one extra polling cadence per visit is the signature of this leak. - Take heap snapshots before and after a few open-close cycles and look for detached view instances retained by subscriber objects. - Review every pipe for **operators that wait on more than one subscription** placed after `takeUntil`: the flattening family and joins such as `combineLatestWith` or `mergeWith`, which complete only when all their sources have. ## When something may legitimately follow takeUntil The rule of thumb is "last", but operators that only react to the notifications they receive and open no subscriptions of their own, such as `finalize`, `map` or `tap`, are safe after it. Operators that keep another subscription alive until it completes are not. Some multi-source operators forward the outer completion and release their other sources, but relying on knowing which ones do is fragile, which is why teams simply keep `takeUntil` last. Lint rules exist in the ecosystem to enforce the placement mechanically, which is worth adopting in a large codebase because the bug is invisible in code review unless you know to look for it. ## The same rule in framework helpers Destroy-bound operators that frameworks provide are built on the same completion mechanism, so they carry the same placement rule: put them last.

  • Why does calling unsubscribe() on the final Subscription not have the same placement problem?
    Unsubscription always travels upstream through every operator, and each operator tears down what it subscribed to, including active inner subscriptions of `switchMap` or `mergeMap`. `takeUntil` in the middle of a pipe unsubscribes only the part above it and sends a completion below it, and completion is something flattening operators are allowed to wait on.
  • Is it safe to put finalize or tap after takeUntil?
    Yes. Operators that only react to the notifications they receive and open no subscriptions of their own simply pass the completion on. The rule is about operators that keep another subscription open until it completes: the flattening family and joins such as `combineLatestWith` or `mergeWith`.
  • How would you prove the leak is fixed without relying on the network panel?
    Add `finalize` inside the inner stream and assert in a test that it runs when the notifier emits, or count active subscriptions with a spy on the inner factory. A virtual-time test can also advance time after teardown and assert that no further values or requests occur.

saying these in an interview costs you the question

  • takeUntil anywhere in the pipe stops everything, including inner streams.
  • switchMap unsubscribes its inner as soon as its outer completes.
  • Completion and unsubscription travel through a pipe in the same direction.
  • The leak means destroy$ never emitted, so the fix is in the teardown hook.
  • Only mergeMap leaks here; switchMap is safe because it cancels previous inners.