Mert Özen Further With Every Line
Frontend RxJS — #09

Error Handling in RxJS: catchError, retry with Backoff, finalize and Catching Errors Without Killing the Stream

Mert Özen Nov 14, 2026 22 min 14 views
Error Handling in RxJS: catchError, retry with Backoff, finalize and Catching Errors Without Killing the Stream

catchError does not catch and continue; it replaces the rest of the stream with the Observable you return, so a long-lived stream survives only if it sits inside switchMap, and retry belongs, with backoff and jitter, only on idempotent requests and 5xx or network errors. With real runs we show the double order, finalize, and in Angular HttpErrorResponse, ASP.NET Core ProblemDetails and how an interceptor and a component share the work.

Version note: The examples in this post were run with RxJS 7.8.2, Angular 22.2, TypeScript 6.0 and Node.js 24 (the Angular project uses TypeScript 6.0, the range Angular 22.2 supports); the ProblemDetails response comes from ASP.NET Core 10. Timed examples run in virtual time on RxJS's TestScheduler, the Angular examples in headless Chromium driven by Playwright 1.65.

Predict First

The search box of Bookstore, the online bookstore used as the shared example across this site's series, should not show an error page when the search API fails. So we add catchError at the end of the pipe and show an empty list instead. The user types "du", "dun" and "dune" one second apart, and the API fails once, for "dun":

import { catchError, concat, NEVER, of, switchMap } from "rxjs";
import { books } from "../../data/books.ts";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeHttp } from "./fake-http.ts";

// the search API fails once, for "dun"
function searchBooks(term: string) {
  const status = term === "dun" ? 503 : 200;
  return fakeHttp(`GET /books?q=${term}`, 200, status, () =>
    books.filter((b) => b.title.toLowerCase().includes(term)).map((b) => b.title),
  );
}

runInVirtualTime("search box, catchError at the end", () => {
  const terms$ = concat(emitAt([0, "du"], [1000, "dun"], [2000, "dune"]), NEVER); // the box stays open
  terms$
    .pipe(
      switchMap((term) => searchBooks(term)),
      catchError(() => of<string[]>([])),
    )
    .subscribe({
      next: (titles) => log(`screen: [${titles.join(", ")}]`),
      complete: () => log("search stream completed"),
    });
});

What does the screen show after "dune" is typed at 2000 ms?

In the previous post a failed request ended up cached by mistake. This post is about handling errors on purpose: what catchError really replaces, how a search box survives a failed request, retry with exponential backoff and jitter, why a retried POST can create two orders, and finalize for loading indicators. In the second half we move to bookstore-web: Angular's HttpErrorResponse, the ProblemDetails body that ASP.NET Core sends, and how a functional interceptor and a component share the work. The virtual-time examples use fakeHttp and flakyHttp from the snapshot (src/09/fake-http.ts): a fake call that answers with a status code and fails with an HttpError for anything but 200.

An Error Ends the Stream

The answer:

--- search box, catchError at the end
    0 ms  -> GET /books?q=du
  200 ms  <- GET /books?q=du 200
  200 ms  screen: [Dune]
 1000 ms  -> GET /books?q=dun
 1200 ms  <- GET /books?q=dun 503
 1200 ms  screen: []
 1200 ms  search stream completed

Nothing. The "dun" request failed at 1200 ms, catchError replaced the error with an empty list, and right after that the whole search stream completed. "dune" at 2000 ms never reached the API: the user can type as much as they like, the search box is dead until the page is reloaded.

The reason is the Observable contract from the first post of this series: after error, a stream sends nothing more. switchMap forwards the inner request's error to the outer stream, so one failed request kills the subscription to the text box as well. catchError does not "catch and continue"; it unsubscribes from the failed stream and subscribes to the Observable you return instead. Here that was of([]), which emits once and completes, so the result completed too. Keep this picture in mind for the rest of the post: catchError swaps the rest of the stream for another one.

Three Strategies: Replace, Rethrow, EMPTY

What you return from catchError decides what the subscriber sees. The same failing stock request, three ways:

import { catchError, EMPTY, of, throwError } from "rxjs";
import { log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeHttp } from "./fake-http.ts";

const stock = () => fakeHttp("GET /books/9780441172719/stock", 200, 503, () => 12);
const report = (label: string) => ({
  next: (v: unknown) => log(`${label} next: ${v}`),
  error: (err: Error) => log(`${label} error: ${err.message}${err.cause ? ` (cause: ${(err.cause as Error).message})` : ""}`),
  complete: () => log(`${label} complete`),
});

runInVirtualTime("1. replace: show the last known value", () => {
  stock().pipe(catchError(() => of("12 (cached)"))).subscribe(report("replace"));
});

runInVirtualTime("2. rethrow: add context, let the caller decide", () => {
  stock()
    .pipe(catchError((err: Error) => throwError(() => new Error("Could not load Dune's stock", { cause: err }))))
    .subscribe(report("rethrow"));
});

runInVirtualTime("3. EMPTY: give up silently", () => {
  stock().pipe(catchError(() => EMPTY)).subscribe(report("empty"));
});
--- 1. replace: show the last known value
    0 ms  -> GET /books/9780441172719/stock
  200 ms  <- GET /books/9780441172719/stock 503
  200 ms  replace next: 12 (cached)
  200 ms  replace complete
--- 2. rethrow: add context, let the caller decide
    0 ms  -> GET /books/9780441172719/stock
  200 ms  <- GET /books/9780441172719/stock 503
  200 ms  rethrow error: Could not load Dune's stock (cause: HTTP 503)
--- 3. EMPTY: give up silently
    0 ms  -> GET /books/9780441172719/stock
  200 ms  <- GET /books/9780441172719/stock 503
  200 ms  empty complete

Replacing with a fallback value fits data that can be a bit old: the last known stock, a cached category list, a default setting. The subscriber gets a value and a normal completion, so it should be able to tell that the value is not fresh, which is why the example marks it. Rethrowing fits a layer that cannot decide what to do, typically a service: it wraps the low-level error in one that says what failed and keeps the original as cause, and the caller handles it. EMPTY completes without a value; use it only when "nothing" is a correct result, for example an optional recommendation box. Used for important data, it is the quietest bug there is: no value, no error, no log.

catchError Inside switchMap: Keeping the Search Box Alive

To keep the outer stream alive, the error must be caught before it reaches it, that is, inside switchMap, on the inner request:

import { catchError, concat, map, NEVER, of, switchMap } from "rxjs";
import { books } from "../../data/books.ts";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeHttp } from "./fake-http.ts";

type SearchResult = { term: string; titles: string[] } | { term: string; error: string };

function searchBooks(term: string) {
  const status = term === "dun" ? 503 : 200;
  return fakeHttp(`GET /books?q=${term}`, 200, status, () =>
    books.filter((b) => b.title.toLowerCase().includes(term)).map((b) => b.title),
  );
}

runInVirtualTime("search box, catchError inside switchMap", () => {
  const terms$ = concat(emitAt([0, "du"], [1000, "dun"], [2000, "dune"]), NEVER);
  terms$
    .pipe(
      switchMap((term) =>
        searchBooks(term).pipe(
          map((titles): SearchResult => ({ term, titles })),
          // the error ends only this inner request; the outer stream keeps listening
          catchError((err: Error) => of<SearchResult>({ term, error: err.message })),
        ),
      ),
    )
    .subscribe((r) =>
      log("error" in r ? `screen: search for "${r.term}" failed (${r.error}), try again` : `screen: [${r.titles.join(", ")}]`),
    );
});
--- search box, catchError inside switchMap
    0 ms  -> GET /books?q=du
  200 ms  <- GET /books?q=du 200
  200 ms  screen: [Dune]
 1000 ms  -> GET /books?q=dun
 1200 ms  <- GET /books?q=dun 503
 1200 ms  screen: search for "dun" failed (HTTP 503), try again
 2000 ms  -> GET /books?q=dune
 2200 ms  <- GET /books?q=dune 200
 2200 ms  screen: [Dune]

Now the failure ended only the "dun" request. catchError turned it into a result with an error message, the screen said "try again", and "dune" at 2000 ms worked normally. The result type is a union (titles or error), so the template has to handle both, and TypeScript makes sure it does. The same rule applies to mergeMap, concatMap and exhaustMap, and to each source of forkJoin in the combining post's exercise: put catchError on the inner Observable whose failure should stay local.

retry with Exponential Backoff and Jitter

Some errors are temporary: a 503 during a deployment, a network hiccup. Retrying them immediately and repeatedly is the worst choice, because a struggling server receives even more requests from every client at the same moment. Exponential backoff doubles the wait after each attempt, and jitter spreads clients randomly within that wait so they do not all retry together. RxJS 7's retry takes a config object for this: count limits the retries, delay is a function that receives the error and the retry number and returns an Observable (retry when it emits, or fail if it errors), and resetOnSuccess resets the counter after a successful value, which matters for long-lived streams. The helpers:

// Exponential backoff with jitter: 500, 1000, 2000… ms (capped), each randomised between 50% and 100%.
import { throwError, timer, type Observable } from "rxjs";
import { HttpError } from "./fake-http.ts";

export function backoffDelay(retryCount: number, random: () => number = Math.random, baseMs = 500, maxMs = 8000): number {
  const ceiling = Math.min(maxMs, baseMs * 2 ** (retryCount - 1));
  return Math.round(ceiling / 2 + (random() * ceiling) / 2);
}

// network errors (status 0) and 5xx may succeed on the next attempt; 4xx will not
export function isRetryable(err: unknown): boolean {
  return err instanceof HttpError && (err.status === 0 || err.status >= 500);
}

// for retry({ delay }): wait for a retryable error, rethrow anything else at once
export function retryDelay(random?: () => number) {
  return (err: unknown, retryCount: number): Observable<unknown> =>
    isRetryable(err) ? timer(backoffDelay(retryCount, random)) : throwError(() => err);
}

// a small seeded random generator (mulberry32), so the examples print the same delays on every run
export function seededRandom(seed: number): () => number {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

retryDelay retries only errors that might succeed next time: status 0 (no response at all) and 5xx. A 404 or a 400 will fail the same way however often you ask, so it is rethrown at once. The seeded random generator is only there so that the examples print the same numbers on every run; real code uses Math.random.

import { retry } from "rxjs";
import { log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { retryDelay, seededRandom } from "./backoff.ts";
import { flakyHttp } from "./fake-http.ts";

const report = (label: string) => ({
  next: (v: unknown) => log(`${label}: ${v}`),
  error: (err: Error) => log(`${label} failed: ${err.message}`),
});

runInVirtualTime("503, 503, 503, then 200", () => {
  flakyHttp("GET /books/9780441172719/stock", 200, [503, 503, 503, 200], () => 12)
    .pipe(retry({ count: 4, delay: retryDelay(seededRandom(42)) }))
    .subscribe(report("stock"));
});

runInVirtualTime("404 is not retried", () => {
  flakyHttp("GET /books/9780000000000", 200, [404], () => "book")
    .pipe(retry({ count: 4, delay: retryDelay(seededRandom(42)) }))
    .subscribe(report("book"));
});

runInVirtualTime("503 every time: give up after 4 retries", () => {
  flakyHttp("GET /categories", 200, [503], () => [])
    .pipe(retry({ count: 4, delay: retryDelay(seededRandom(42)) }))
    .subscribe(report("categories"));
});
--- 503, 503, 503, then 200
    0 ms  -> GET /books/9780441172719/stock
  200 ms  <- GET /books/9780441172719/stock 503
  600 ms  -> GET /books/9780441172719/stock
  800 ms  <- GET /books/9780441172719/stock 503
 1524 ms  -> GET /books/9780441172719/stock
 1724 ms  <- GET /books/9780441172719/stock 503
 3576 ms  -> GET /books/9780441172719/stock
 3776 ms  <- GET /books/9780441172719/stock 200
 3776 ms  stock: 12
--- 404 is not retried
    0 ms  -> GET /books/9780000000000
  200 ms  <- GET /books/9780000000000 404
  200 ms  book failed: HTTP 404
--- 503 every time: give up after 4 retries
    0 ms  -> GET /categories
  200 ms  <- GET /categories 503
  600 ms  -> GET /categories
  800 ms  <- GET /categories 503
 1524 ms  -> GET /categories
 1724 ms  <- GET /categories 503
 3576 ms  -> GET /categories
 3776 ms  <- GET /categories 503
 7115 ms  -> GET /categories
 7315 ms  <- GET /categories 503
 7315 ms  categories failed: HTTP 503

In the first scenario the waits between attempts were 400, 724 and 1852 ms: each one between half and all of its ceiling (500, 1000, 2000 ms), and the fourth attempt succeeded. The user waited 3.8 seconds instead of seeing an error. The 404 failed after one attempt, as it should. In the third scenario the server never recovered; after four retries and 7.3 seconds the error reached the subscriber. Choose count and the ceilings with the user in mind: retrying for a minute behind a spinner is worse than an honest error message after a few seconds.

retry and POST: The Double Order

Retrying is only safe for operations that give the same result when repeated. A GET is like that; placing an order is not. In the next example the server creates the order as soon as the request arrives but answers slowly, and the client gives up after one second and retries:

import { retry, timeout, timer } from "rxjs";
import { log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "../04/fake-api.ts";

// the server creates the order as soon as the request arrives, but answers slowly (1500 ms)
function bookstoreServer() {
  const orders = new Map<string, number>(); // idempotency key -> order id
  let nextId = 1043;
  return {
    placeOrder(idempotencyKey?: string) {
      return fakeRequest(`POST /orders${idempotencyKey ? ` (Idempotency-Key: ${idempotencyKey})` : ""}`, 1500, () => "201", () => {
        if (idempotencyKey && orders.has(idempotencyKey)) {
          log(`   server: key seen before, returns order ${orders.get(idempotencyKey)}`);
          return;
        }
        const id = nextId++;
        if (idempotencyKey) orders.set(idempotencyKey, id);
        log(`   server: order ${id} created`);
      });
    },
  };
}

runInVirtualTime("retry on a POST: every attempt is a new order", () => {
  const server = bookstoreServer();
  server
    .placeOrder()
    .pipe(timeout(1000), retry({ count: 2, delay: () => timer(500) }))
    .subscribe({ next: (r) => log(`client: ${r}`), error: (err: Error) => log(`client: ${err.name}`) });
});

runInVirtualTime("the same retry with an idempotency key", () => {
  const server = bookstoreServer();
  const key = "c1f0e6a2"; // created once per click, sent with every attempt
  server
    .placeOrder(key)
    .pipe(timeout(1000), retry({ count: 2, delay: () => timer(500) }))
    .subscribe({ next: (r) => log(`client: ${r}`), error: (err: Error) => log(`client: ${err.name}`) });
});
--- retry on a POST: every attempt is a new order
    0 ms  -> POST /orders
    0 ms     server: order 1043 created
 1000 ms  x  POST /orders cancelled
 1500 ms  -> POST /orders
 1500 ms     server: order 1044 created
 2500 ms  x  POST /orders cancelled
 3000 ms  -> POST /orders
 3000 ms     server: order 1045 created
 4000 ms  x  POST /orders cancelled
 4000 ms  client: TimeoutError
--- the same retry with an idempotency key
    0 ms  -> POST /orders (Idempotency-Key: c1f0e6a2)
    0 ms     server: order 1043 created
 1000 ms  x  POST /orders (Idempotency-Key: c1f0e6a2) cancelled
 1500 ms  -> POST /orders (Idempotency-Key: c1f0e6a2)
 1500 ms     server: key seen before, returns order 1043
 2500 ms  x  POST /orders (Idempotency-Key: c1f0e6a2) cancelled
 3000 ms  -> POST /orders (Idempotency-Key: c1f0e6a2)
 3000 ms     server: key seen before, returns order 1043
 4000 ms  x  POST /orders (Idempotency-Key: c1f0e6a2) cancelled
 4000 ms  client: TimeoutError

Three attempts, three orders: 1043, 1044 and 1045, and the client still reports a failure. For the customer this is the worst outcome possible: an error on the screen and three charges on the card. The x lines are a reminder that cancelling a request on the client does not undo anything on the server. In the second scenario every attempt carried the same idempotency key, created once per click; the server recognised the key and returned the existing order 1043. How the server stores and checks such keys is covered in the "Idempotency Key: Çift Tıklama ve Retry'da Aynı Siparişi İki Kez Oluşturmamak" post of the Çözüm Desenleri series. On the client side the rule is short: no retry on a POST without an idempotency key, and the key must stay the same across retries.

finalize for Loading Indicators

A loading indicator must disappear in every case: after success, after an error, and when the request is cancelled. Turning it off in next and error misses the third case. finalize runs when the subscription ends, for whatever reason:

import { concat, finalize, NEVER, switchMap } from "rxjs";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeHttp } from "./fake-http.ts";

let loading = false;

function load(label: string, status: number) {
  loading = true;
  log(`${label}: loading = true`);
  return fakeHttp(`GET /books (${label})`, 300, status, () => 6).pipe(
    // runs on complete, on error and on unsubscribe
    finalize(() => {
      loading = false;
      log(`${label}: finalize, loading = false`);
    }),
  );
}

runInVirtualTime("success, error, cancel", () => {
  load("success", 200).subscribe({ next: (n) => log(`success: ${n} books`), complete: () => log("success: complete") });
  emitAt([1000, "x"]).subscribe(() => load("error", 503).subscribe({ error: (err: Error) => log(`error: ${err.message}`) }));
  // a newer request cancels this one through switchMap
  concat(emitAt([2000, "first"], [2100, "second"]), NEVER)
    .pipe(switchMap((label) => load(label, 200)))
    .subscribe((n) => log(`switchMap: ${n} books, loading = ${loading}`));
});
--- success, error, cancel
    0 ms  success: loading = true
    0 ms  -> GET /books (success)
  300 ms  <- GET /books (success) 200
  300 ms  success: 6 books
  300 ms  success: complete
  300 ms  success: finalize, loading = false
 1000 ms  error: loading = true
 1000 ms  -> GET /books (error)
 1300 ms  <- GET /books (error) 503
 1300 ms  error: HTTP 503
 1300 ms  error: finalize, loading = false
 2000 ms  first: loading = true
 2000 ms  -> GET /books (first)
 2100 ms  first: finalize, loading = false
 2100 ms  second: loading = true
 2100 ms  -> GET /books (second)
 2400 ms  <- GET /books (second) 200
 2400 ms  switchMap: 6 books, loading = true
 2400 ms  second: finalize, loading = false

The flag went back to false after the success, after the 503 and when switchMap cancelled the "first" request at 2100 ms; in that last case neither next nor error ever ran. Two details are visible in the output. finalize runs after the subscriber's own callbacks (after "success: complete", after "error: HTTP 503"), so inside next the flag is still true, as the "switchMap: 6 books, loading = true" line shows. And one shared flag works with switchMap only because there is never more than one request; with mergeMap the first request to finish would switch the indicator off while others are still running. In that case count the requests in flight instead of using a boolean.

Angular: HttpErrorResponse, ProblemDetails and the Interceptor

In Angular every failed HttpClient call ends with an HttpErrorResponse. Its status is the HTTP status, or 0 when no response arrived (network error, CORS rejection, server not running), and its error property holds the parsed response body. ASP.NET Core sends errors as ProblemDetails with application/problem+json; the "ASP.NET Core'da Global Hata Yönetimi: Exception Middleware ve ProblemDetails" post of the ASP.NET Core Web API series shows the server side. This is what an ASP.NET Core 10 endpoint returned for TypedResults.Problem with status 409; the mock API in the snapshot answers POST /api/orders the same way when a quantity exceeds the stock, and with a ValidationProblem-shaped 400 for an invalid quantity:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{"type":"https://bookstore.example.com/problems/out-of-stock","title":"Not enough stock","status":409,"detail":"Only 3 copies of Middlemarch are in stock.","traceId":"00-f39f136fc215c755185bd077da552e91-f0fe67a39aa75f85-00"}

bookstore-web turns any such error into a sentence for the user in one place:

import { HttpErrorResponse } from '@angular/common/http';

// The body ASP.NET Core sends with application/problem+json (RFC 9457)
export interface ProblemDetails {
  type?: string;
  title?: string;
  status?: number;
  detail?: string;
  traceId?: string;
  errors?: Record<string, string[]>;
}

function isProblemDetails(body: unknown): body is ProblemDetails {
  return typeof body === 'object' && body !== null && ('title' in body || 'detail' in body);
}

// a message the user can read, from whatever the HTTP call failed with
export function errorMessage(err: unknown): string {
  if (!(err instanceof HttpErrorResponse)) return 'Something went wrong.';
  if (err.status === 0) return 'Cannot reach the Bookstore service. Check your connection.';
  if (isProblemDetails(err.error)) {
    const validation = Object.values(err.error.errors ?? {}).flat();
    return validation.length ? validation.join(' ') : (err.error.detail ?? err.error.title ?? err.message);
  }
  return err.status >= 500 ? 'The Bookstore service is not responding. Please try again.' : err.message;
}

The more important question is who handles which error. Two places are involved, and they should not do each other's job. A functional interceptor sees every request; it is the right place for things that are the same everywhere: retrying idempotent GETs, logging, and telling the user that the whole service is down. A component knows what an error means in its own context: that 409 on the cart page means "reduce the quantity", or that the catalog can show an empty list. The interceptor:

import { HttpErrorResponse, HttpResponse, type HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, retry, tap, throwError, timer } from 'rxjs';
import { ServerStatus } from './server-status';

// network errors (status 0) and 5xx may succeed on the next attempt; 4xx will not
const isRetryable = (err: unknown) =>
  err instanceof HttpErrorResponse && (err.status === 0 || err.status >= 500);

// 500, 1000, 2000 ms ceilings, randomised between 50% and 100%
const backoff = (retryCount: number) => {
  const ceiling = 500 * 2 ** (retryCount - 1);
  return ceiling / 2 + (Math.random() * ceiling) / 2;
};

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const serverStatus = inject(ServerStatus);
  // only GET is safe to repeat; a POST could create a second order
  const retries = req.method === 'GET' ? 3 : 0;

  return next(req).pipe(
    retry({
      count: retries,
      delay: (err, retryCount) => {
        if (!isRetryable(err)) return throwError(() => err);
        console.warn(`[http] ${req.method} ${req.url} failed (${err.status}), retry ${retryCount}`);
        return timer(backoff(retryCount));
      },
    }),
    tap((event) => {
      if (event instanceof HttpResponse) serverStatus.report(false);
    }),
    catchError((err: unknown) => {
      // central part: record it and show the global banner; the caller still gets the error
      if (isRetryable(err)) serverStatus.report(true);
      console.error(`[http] ${req.method} ${req.url} failed:`, err instanceof HttpErrorResponse ? err.status : err);
      return throwError(() => err);
    }),
  );
};

It retries only GETs, only for 0 and 5xx, with the same backoff idea as before, and on a final failure it switches on a global banner through ServerStatus, a small service with a signal. Then it rethrows, because only the caller knows what to show in place of the data. The interceptor is registered with provideHttpClient(withInterceptors([errorInterceptor])) in app.config.ts. On the catalog page the component part is one line, a fallback to an empty list:

  protected readonly books = toSignal(
    inject(HttpClient)
      .get<Book[]>(`${apiUrl}/books`)
      // the interceptor already retried and showed the banner; here the page just stays usable
      .pipe(catchError(() => of<Book[]>([]))),
    { initialValue: [] },
  );

We started the mock API with FAIL=/api/books:2, so the first two book requests answer 503, and opened the catalog in headless Chromium:

 0.3 s GET /api/books 503
 0.3 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 0.3 s [console.warning] [http] GET http://localhost:5180/api/books failed (503), retry 1
 0.3 s GET /api/categories 200
 0.9 s GET /api/books 503
 0.9 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 0.9 s [console.warning] [http] GET http://localhost:5180/api/books failed (503), retry 2
 2.0 s GET /api/books 200
 4.1 s books on screen: 6 | banner: (none)

Two failures, two retries after about 0.6 and 1.1 seconds, and the catalog appeared with all six books; the user saw a short delay, not an error. With FAIL=/api/books:9 the service never recovered:

 0.3 s GET /api/books 503
 0.3 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 0.3 s [console.warning] [http] GET http://localhost:5180/api/books failed (503), retry 1
 0.3 s GET /api/categories 200
 1.0 s GET /api/books 503
 1.0 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 1.0 s [console.warning] [http] GET http://localhost:5180/api/books failed (503), retry 2
 1.8 s GET /api/books 503
 1.8 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 1.8 s [console.warning] [http] GET http://localhost:5180/api/books failed (503), retry 3
 3.6 s GET /api/books 503
 3.6 s [console.error] Failed to load resource: the server responded with a status of 503 (Service Unavailable)
 3.6 s [console.error] [http] GET http://localhost:5180/api/books failed: 503
 6.1 s books on screen: 0 | banner: The Bookstore service is not responding. Please try again later.

Four attempts, then the banner, and the page stayed usable with an empty list. Without the catchError in the component, Angular logged ERROR HttpErrorResponse to the console in our first run of this scenario: a signal created with toSignal rethrows the Observable's error every time it is read, here in the template. The Angular post of this series looks at toSignal more closely.

The cart page places the order. It manages its own loading state with finalize and handles the error itself, because the meaning of 409 belongs to this page:

  protected placeOrder(): void {
    this.placing.set(true);
    this.message.set(null);
    this.#orders
      .placeOrder(this.cart.items())
      // the button comes back whatever happens: success or error
      .pipe(finalize(() => this.placing.set(false)))
      .subscribe({
        next: (order) => this.message.set(`Order ${order.id} placed.`),
        // component level: this page knows what a 409 means for the user
        error: (err: unknown) => this.message.set(errorMessage(err)),
      });
  }

We added Middlemarch four times (its stock is 3), went to the cart and clicked "Place order":

 0.3 s GET /api/books 200
 0.3 s GET /api/categories 200
 0.6 s button while waiting: Placing order... | disabled: true
 0.8 s POST /api/orders 409
 0.8 s [console.error] Failed to load resource: the server responded with a status of 409 (Conflict)
 0.8 s [console.error] [http] POST http://localhost:5180/api/orders failed: 409
 0.9 s message: Only 3 copies of Middlemarch are in stock. | button: Place order | disabled: false
[api] listening on http://localhost:5180
[api] #1 GET /api/books
[api] #2 GET /api/categories
[api] #1 200
[api] #2 200
[api] #3 OPTIONS /api/orders
[api] #3 204 preflight
[api] #4 POST /api/orders
[api] #4 409 problem+json

While the request was running the button said "Placing order..." and was disabled, so a second click could not send a second POST. The 409 was not retried (POST, and 4xx anyway), the interceptor logged it, and the page showed the detail from the ProblemDetails body. finalize enabled the button again. The OPTIONS request in the server log is the browser's CORS preflight, because the page and the API run on different ports.

In Production

An error that only reaches the browser console does not exist for the team. Send the ones that matter to an error tracking service (Sentry, Application Insights or similar) from the interceptor or from Angular's ErrorHandler, and include the traceId from the ProblemDetails body: with it, the backend team can find the exact request in their logs. Showing a short reference to the user ("error reference: f39f136f") makes support tickets useful as well.

Watch the retry traffic. Every retry is a real request; during an outage, three retries per client mean up to four times the normal load on a service that is already struggling. Jitter spreads that load, and a short count limits it. If the server sends a Retry-After header with 503 or 429, respect it in the delay function instead of your own backoff. And measure: a dashboard that shows the share of requests that succeeded only after a retry tells you about instability long before users complain.

Finally, be careful with fallback values. An empty list after an error looks exactly like "no books found". Users then report "the search does not find Dune" instead of "the search is broken", and the real problem takes longer to find. Where it matters, show that the data is missing because of an error, as the search box above did.

Common Pitfalls

  • Putting catchError at the end of a long-lived stream. It replaces the rest of the stream; the search box or the polling stops for good. Catch inside switchMap.
  • Swallowing errors with EMPTY or an empty fallback and showing nothing. The user sees an empty page and nobody sees the error.
  • Retrying a POST without an idempotency key. A timeout followed by a retry can create two orders.
  • Retrying 4xx errors. A 400, 404 or 409 fails the same way every time; retry only 0 and 5xx.
  • Retrying immediately and without a limit. Use retry({ count, delay }) with backoff and jitter.
  • Turning off a loading indicator in next and error. A cancelled request calls neither; use finalize.
  • Handling every error in the interceptor, including what the page should display. The interceptor does what is the same everywhere; the meaning of an error belongs to the component.

A Small Experiment

★☆☆ In 04-retry-backoff.ts, change the seed from 42 to 7 and predict which waits change and which limits they stay within. Then set count: 2 in the first scenario; what does the subscriber get?

★★☆ Make the cart page send an idempotency key: create a key with crypto.randomUUID() once per click, send it as an Idempotency-Key header, and let the interceptor retry a POST only when that header is present. Extend the mock API so that it remembers keys, and check in the server log that a retried POST creates only one order.

★★★ Add support for Retry-After to the interceptor: when a 503 or 429 response has the header (in seconds), wait that long instead of the backoff. Test it by letting the mock API send Retry-After: 2 with its 503 answers, and measure the gaps in the Playwright log.

In this post bookstore-web used toSignal, async and an interceptor without asking much about them. In the next post we look at RxJS inside Angular 22 as a whole: which Angular APIs return Observables and what their signal versions are, toSignal, toObservable, rxResource and outputFromObservable, why an Observable does not update the screen by itself in a zoneless OnPush application, the search box written three ways, and how to manage subscriptions without leaks.