skip to content

In RxJS 7, how would you retry a flaky price endpoint with exponential backoff using retry's config, and what do count, delay and resetOnSuccess control?

level: seniorimportance: should knowfreq 62%

answer

  1. a config object, not retryWhen
  2. delay can be a function
  3. retryCount starts at one
  4. the notifier decides: emit, error, complete
  5. reset the budget after success

basics

~10 s

Use retry({ count, delay }) where delay is a function of (error, retryCount) returning timer(base * 2 ** (retryCount - 1)); count caps retries, and resetOnSuccess restarts the count after the source emits again.

solid answer

~40 s

RxJS 7's `retry` takes a config: `count` is the maximum number of **retries** (default unlimited), `delay` is either milliseconds or a function `(error, retryCount) => ObservableInput`, and `resetOnSuccess` (default `false`) resets the counter whenever the resubscribed source emits a value. For backoff, the function returns `timer(500 * 2 ** (retryCount - 1))` — `retryCount` is 1 on the first retry — giving 500, 1000, 2000 ms, ideally with random jitter. The notifier returned by `delay` decides: when it **emits**, `retry` resubscribes; when it **errors**, that error ends the stream, which is how you give up early on a non-retryable error with `throwError(() => err)`; when it **completes without emitting**, the result completes silently. `retryWhen` is deprecated in favour of this `delay` option.

code

ts · 11 lines
ts
import { EMPTY, retry, throwError } from 'rxjs';

let attempts = 0;
const flaky$ = throwError(() => new Error(`fail ${++attempts}`));

flaky$
  .pipe(retry({ count: 2, delay: () => EMPTY }))
  .subscribe({
    error: (e) => console.log('error', e.message),
    complete: () => console.log('completed silently'), // this runs
  });

go deeper

for a junior

Recall that retry resubscribes to a failing source and that RxJS 7 retry accepts count and delay options.

for a middle

Explain count as retries rather than attempts, delay as a number or a function returning a notifier, and resetOnSuccess's default of false.

for a senior

Write backoff with jitter, refuse non-transient errors by erroring the notifier, avoid the EMPTY-completes trap, and use resetOnSuccess on long-lived streams.

for a principal

Set retry budgets with the backend's capacity in mind: which errors are retryable, how retries from many clients add up, and where a circuit breaker should replace retries.

## The retry config in RxJS 7 `retry` resubscribes to its source when the source errors. RxJS 7 added a configuration object that covers what used to need `retryWhen`: | Option | Type | Default | Meaning | |---|---|---|---| | `count` | number | unlimited | maximum number of **retries** after the first attempt | | `delay` | number, or `(error, retryCount) => ObservableInput` | none (retry at once) | wait before each retry, or decide per error | | `resetOnSuccess` | boolean | `false` | reset the retry counter when the resubscribed source emits a value | `retry(3)` is shorthand for `retry({ count: 3 })`, and `retry()` with no argument retries forever. ## Exponential backoff for a flaky price endpoint A price endpoint sometimes answers with a transient failure (a timeout, a 503). Retrying at once hammers it while it is already struggling; retrying after growing pauses gives it time to recover. ```ts import { Observable, retry, throwError, timer } from 'rxjs'; interface Price { sku: string; amount: number } declare function fetchPrice(sku: string): Observable<Price>; // cold: each subscribe sends a request declare function isTransient(error: unknown): boolean; // e.g. timeouts and 5xx const price$ = fetchPrice('A-100').pipe( retry({ count: 4, delay: (error, retryCount) => { if (!isTransient(error)) { return throwError(() => error); // give up at once } const backoff = 500 * 2 ** (retryCount - 1); // 500, 1000, 2000, 4000 ms const jitter = Math.random() * 250; return timer(backoff + jitter); }, }), ); ``` How it runs: 1. The first subscription sends the request. It fails. 2. `retry` increments its counter and calls `delay(error, 1)`. The function returns `timer(~500)`. 3. When the timer **emits**, `retry` resubscribes to the source, which sends the request again. 4. Each further failure calls `delay` with `retryCount` 2, 3, 4, doubling the wait. 5. After the fourth retry fails, the count is exhausted and the last error goes downstream. With `count: 4`, the endpoint is called **at most five times**: the original attempt plus four retries. ## The notifier decides what happens When `delay` is a function, the observable it returns is a **notifier**: - **It emits** → `retry` resubscribes to the source. - **It errors** → that error is delivered downstream and retrying stops. Returning `throwError(() => error)` is the clean way to refuse to retry a non-transient error such as a 404 or a validation failure. - **It completes without emitting** → the result **completes** without error. Returning `EMPTY` to "give up" therefore turns a failure into a silent success — a classic trap. A number instead of a function (`delay: 1000`) is a fixed pause between retries. ## resetOnSuccess for long-lived streams For a one-shot request, the counter never needs resetting. For a **long-lived** stream — a price ticker that polls or stays connected for hours — five failures spread over a day would otherwise exhaust `count: 4` and kill the stream for good. With `resetOnSuccess: true`, the counter returns to zero whenever the resubscribed source emits a value, so the budget applies to **consecutive** failures only. The backoff restarts from the shortest delay as well, since `retryCount` restarts too. ## Things to know before retrying - **Retry only cold sources.** `retry` resubscribes; that re-runs the work only if subscribing starts it, as it does for a request observable. - **Values from failed attempts are passed through.** If an attempt emits some values and then errors, those values reach the subscriber, and the retry emits them again. - **Add jitter.** Many clients failing at the same moment and retrying on the same schedule arrive together again; a random component spreads them out. - **Place `retry` before any fallback.** A `catchError` above `retry` would swallow the error before `retry` sees it. ## Migrating from retryWhen `retryWhen(errors$ => ...)` received a stream of errors and returned a notifier. It is deprecated in RxJS 7 — its deprecation note says it will be removed in v9 or v10 — in favour of `retry`'s `delay` option, which receives each error and the retry count directly and needs no hand-written counting with `scan` or `zip`.

  • Why can retry with a delay function turn a failure into a silent success?
    When the notifier returned by `delay` completes without emitting, `retry` completes the result instead of retrying or erroring. Returning `EMPTY` to mean 'stop retrying' therefore hides the failure. To give up and surface the error, return `throwError(() => error)` from the delay function.
  • What does resetOnSuccess change for a long-lived price ticker?
    With the default `false`, retries are counted across the stream's whole life, so occasional failures hours apart eventually exhaust `count` and end the stream. With `resetOnSuccess: true`, the counter resets whenever the resubscribed source emits a value, so `count` limits consecutive failures only, and the backoff restarts from its first step.
  • How many times is the endpoint called with retry({ count: 3 }) if every attempt fails?
    Four times: the original subscription plus three retries. After the third retry fails, `count` is exhausted and the last error is passed downstream.

saying these in an interview costs you the question

  • retryWhen is still the recommended way to add a delay between retries
  • retry({ count: 3 }) sends the request three times in total
  • Returning EMPTY from the delay function makes retry rethrow the error
  • retryCount passed to the delay function starts at zero
  • resetOnSuccess is on by default, so the counter always resets after a success