In Angular, what happens when an Observable bound with the async pipe errors, and how do you render an error state instead?
answer
- not shown in the template
- the application's ErrorHandler
- changed in v20
- subscription is finished
- catch in the stream, map to state
basics
~20 sAngular's async pipe reports the error to the application's ErrorHandler; the errored subscription is over and the binding keeps its last value or null, so render an error state by catching the error in the stream and mapping it to a value.
solid answer
~40 s`AsyncPipe` subscribes with an `error` callback that passes the error to the application's `ErrorHandler.handleError`. Since v20 the pipe does this directly; before that the error went unhandled by the pipe and, in zone-based apps, zone.js caught it and reported it to the same handler, which is why the v20 note flags differences in tests. An error terminates an RxJS subscription, and the pipe does not retry, so the binding keeps returning its **last value**, or `null` if nothing was emitted, and the user sees stale data or an empty section. To show an error, handle it in the stream: `catchError` maps the failure into a value such as `{ state: 'error' }` or an empty list, and the template branches on that value.
code
ts · 27 linesimport { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { catchError, map, of } from 'rxjs';
interface Notification { id: number; text: string; }
@Component({
selector: 'app-notification-panel',
imports: [AsyncPipe],
template: `
@let vm = vm$ | async;
@if (vm === null) {
<p>Loading...</p>
} @else if (vm.state === 'error') {
<p role="alert">{{ vm.message }}</p>
} @else {
@for (n of vm.items; track n.id) { <p>{{ n.text }}</p> }
}
`,
})
export class NotificationPanel {
readonly vm$ = inject(HttpClient).get<Notification[]>('/api/notifications').pipe(
map(items => ({ state: 'ok' as const, items })),
catchError(() => of({ state: 'error' as const, message: 'Could not load notifications' })),
);
}go deeper
Know that an error in a stream bound with the async pipe is logged by Angular, not shown in the template, so you must handle it in the stream.
Explain the ErrorHandler route, the v20 change to a direct ErrorHandler call, and that the binding keeps its last value or null after the subscription ends.
Design streams that turn failures into view states with catchError, place error handling so retries keep working, and keep ErrorHandler for reporting.
Standardise loading, error and empty view states across the codebase and decide what the global ErrorHandler reports versus what components render.
## What the pipe does with an error When `AsyncPipe` subscribes to an Observable it passes two callbacks: `next`, which stores the value and marks the view for check, and `error`, which hands the error to Angular's **application error handler**. That handler calls `handleError` on the application's `ErrorHandler` (the default one logs to the console; a custom `ErrorHandler` provider can forward it to a monitoring backend). Rejected Promises bound with `| async` are reported the same way. The error never reaches the template. There is no error value in the pipe's result and no output to listen to. ## The v20 change | Version | How the error reached `ErrorHandler` | |---|---| | before v20 | the pipe left the error unhandled; in a zone-based app zone.js caught it and reported it to `ErrorHandler` | | v20 and later | the pipe catches it and calls the application's `ErrorHandler` itself | The v20 changelog notes that for zone-based applications the result is generally the same, but tests that expected the old mechanism may need updating. The direct call matters for **zoneless** applications, the default for new projects since v21, where there is no zone to catch an error the pipe leaves unhandled. ## What the user sees afterwards In RxJS an `error` notification **ends the subscription**; the source emits nothing more. The async pipe does not resubscribe on its own, and it does not clear its stored value: - If the stream had emitted before failing, the binding keeps showing that **last value**. - If it failed first (a typical failed HTTP call), the binding stays **`null`** until the bound reference changes or the view is recreated, so an `@if (x$ | async; as x)` block simply never appears. - Nothing on screen says anything went wrong. That silent state is the real problem interviewers probe: the error is logged, but the UI looks like it is still loading or shows stale data. ## Rendering an error state Handle the failure **inside the stream**, so the pipe receives an ordinary value: 1. Map successes and failures into one type, for example a small discriminated union such as `{ state: 'ok', items }` or `{ state: 'error', message }`. 2. Put `catchError` at the point where a failure should turn into that value, returning `of({ state: 'error', ... })`. 3. Branch in the template on `vm.state`. ```ts readonly vm$ = this.http.get<Notification[]>('/api/notifications').pipe( map(items => ({ state: 'ok' as const, items })), catchError(() => of({ state: 'error' as const, message: 'Could not load notifications' })), ); ``` For a **retry button**, the request has to be rebuilt from a trigger (a subject or signal the button writes) with the `catchError` placed on the inner request, so one failure does not end the outer stream. Those operator choices belong to RxJS itself; the async-pipe point is only that the error must become a value before it reaches the pipe. ## What not to do - Do not expect `try`/`catch` in a template or around the binding: the error is delivered later, asynchronously. - Do not rely on a custom `ErrorHandler` to render UI: it is a global sink for logging and reporting, not a way to talk to one component's template. - Do not swallow errors with an empty `catchError(() => EMPTY)` unless an unchanged screen really is the intended result. ## What interviewers listen for - Errors go to **`ErrorHandler`**, not to the template. - The subscription is **over** after an error; the binding keeps its **last value or `null`**. - An error state is a **value** produced by `catchError` in the stream.
- Why did the v20 change matter for zoneless applications?Before v20 the pipe left subscription errors unhandled, and in zone-based apps zone.js caught them and reported them to `ErrorHandler`. Without zone.js, nothing caught them on Angular's behalf. Since v20 the pipe calls the application's error handler itself, so reporting no longer depends on zone.js.
- After a failed HTTP call bound with `| async`, will a later change-detection pass retry it?No. The pipe keeps the same errored source and does not resubscribe while the reference is unchanged. A retry needs a new subscription, either from a new Observable reference or from a stream built around a trigger that re-runs the request.
saying these in an interview costs you the question
- The async pipe renders the error message in the template
- The async pipe resubscribes automatically after an error
- The async pipe resets the binding to null when the stream errors
- A try/catch around the binding catches async pipe errors
- A custom ErrorHandler is the way to show an error state in a component