In RxJS, when does a finalize() callback run, and why is it more reliable than tap's complete handler for clearing a loading flag?
answer
- three ways a subscription ends
- complete, error, unsubscribe
- runs after the notification
- tap has a finalize handler too
basics
~20 sfinalize runs whenever its subscription ends — on completion, error or unsubscription — while tap's complete handler runs only on completion, so a loading flag cleared there stays set after an error or a cancel.
solid answer
~40 sA subscription can end in three ways: the source **completes**, the source **errors**, or the consumer **unsubscribes** — for example because a newer request replaced it. `finalize(callback)` runs its callback in all three cases, because it registers the callback as teardown on the subscription at its position. `tap({ complete })` runs only on the first: after an error or a cancellation, a loading spinner cleared there stays on screen forever. On completion or error, the notification is delivered downstream first and the `finalize` callback runs afterwards, during teardown. `tap` also accepts a `finalize` handler with the same timing as the operator. Place it deliberately: it fires when the subscription **at that point** of the chain is torn down.
code
ts · 8 linesimport { finalize, throwError } from 'rxjs';
throwError(() => new Error('price lookup failed'))
.pipe(finalize(() => console.log('finalize')))
.subscribe({ error: (e) => console.log('error:', e.message) });
// error: price lookup failed
// finalizego deeper
Recall that finalize runs when a stream completes, errors or is unsubscribed, which makes it the place for cleanup such as hiding a spinner.
Explain the three ways a subscription ends, why tap's complete handler misses two of them, and that finalize runs after the notification has been delivered.
Place finalize on the subscription whose end you mean — per inner request or per whole stream — and keep outcome handling in catchError, not finalize.
Standardise resource and loading-state cleanup so every request path, including cancellations, releases what it acquired without per-screen special cases.
## Three ways a subscription ends Every RxJS subscription ends in exactly one of three ways: 1. **Completion** — the source calls `complete()`, for example after a request's single response. 2. **Error** — the source calls `error(err)`, for example when the request fails. 3. **Unsubscription** — the consumer calls `unsubscribe()`, or an operator does it on the consumer's behalf: a newer request replaces an older one, a component is destroyed, `take(1)` has what it needs. Cleanup that must always happen — hiding a spinner, releasing a lock, closing a resource, logging the end of an operation — has to cover **all three**. ## What finalize does `finalize(callback)` returns an operator that mirrors its source unchanged and registers `callback` as **teardown** for the subscription at that point in the chain. Teardown runs whenever that subscription closes, whichever of the three causes closed it. | Way the subscription ends | `tap({ complete })` | `tap({ error })` | `finalize()` | |---|---|---|---| | source completes | runs | — | runs | | source errors | — | runs | runs | | consumer unsubscribes | — | — | runs | `tap` also accepts a `finalize` handler — `tap({ finalize: () => ... })` — which the RxJS docs describe as no different from the `finalize` operator. ## The loading-flag bug ```ts import { Observable, finalize, tap } from 'rxjs'; interface Price { sku: string; amount: number } declare function fetchPrice(sku: string): Observable<Price>; let loading = false; function loadBuggy(sku: string) { loading = true; return fetchPrice(sku).pipe( tap({ complete: () => (loading = false) }), // misses errors and cancels ); } function load(sku: string) { loading = true; return fetchPrice(sku).pipe( finalize(() => (loading = false)), // runs on all three ); } ``` With `loadBuggy`, a failed request leaves `loading` true, and so does a request that was cancelled because the user picked another product. With `load`, the flag is cleared in every case. ## Timing: after the notification When the source completes or errors, the notification is first delivered **downstream** — through the remaining operators to the subscriber's own `complete` or `error` callback — and only then is the subscription torn down, running the `finalize` callback. Two practical consequences: - Code in the subscriber's `error` callback runs **before** the `finalize` callback. Do not rely on `finalize` having run inside that callback. - For an unsubscription there is no notification at all: `finalize` runs during the `unsubscribe()` call itself. ## Placement matters `finalize` fires when **its own** subscription ends, and its position decides which subscription that is: - **At the end of a one-shot request pipeline**, it fires when the request finishes, fails or is cancelled — the usual choice for per-request cleanup. - **On an inner observable inside a flattening operator**, it fires once per inner request, including requests cancelled by a newer one. - **On a long-lived outer stream**, it fires only when that whole stream ends, which may be never while the screen is open. ## Things finalize is not - **It cannot change the outcome.** It receives no arguments — not the error, not the last value — and returns nothing. Use `catchError` to react to the error itself. - **It is not a place for asynchronous work the stream waits on.** The stream has already ended; nothing downstream waits for the callback. - **It does not run on subscription.** For "on start" logic, `tap` accepts a `subscribe` handler.
- Does finalize run when a newer request cancels an older one inside a flattening operator?Yes, if the `finalize` is on the inner request observable. Cancelling unsubscribes that inner subscription, and unsubscription runs its teardown, so the callback fires even though the request neither completed nor errored. A `tap({ complete })` on the same observable would not run.
- Can finalize read the error that ended the stream?No. The callback takes no arguments and cannot change what the subscriber receives. To inspect or replace the error, use `catchError` or `tap({ error })`; keep `finalize` for cleanup that must happen however the stream ended.
saying these in an interview costs you the question
- finalize runs only when the source completes successfully
- finalize receives the error so it can decide how to recover
- finalize runs before the subscriber's own error callback
- tap's complete handler also runs when the subscription is unsubscribed
- finalize at the end of a long-lived stream runs after each inner request