Handling errors in an Angular application can take many forms, each with its own trade-offs. Here, we walk through the most practical options.
HTTP errors
Failures during HTTP requests are the most frequent source of errors. Depending on the goal, you might want to react to the failure, substitute a fallback value, or completely ignore it. A key question is where this recovery should take place: inside a component, a service, or a store?
When an HTTP request fails, Angular normalizes the issue into an instance of HttpErrorResponse. You encounter this wrapper both when the server returns an error (e.g., a 500) and when the client sends an invalid request.
Fields worth noting on this class include:
class HttpErrorResponse {
message: string;
error: any | null;
status: number;
statusText: string;
url: string | null;
...
}
That object is what gets passed as the error to the stream you subscribed to. Inspecting properties like status lets you distinguish between different types of failures, for instance detecting an expired user session.
What strategies can you deploy to bounce back from a failure?
retry
Because HttpClient exposes every call as an Observable, you can rely on standard RxJS utilities.
One such utility is retry, which re-invokes the request if it fails. The operator accepts an optional count of retries:
import { retry } from 'rxjs';
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
retry(3)
);
}
Beyond a simple number, retry also supports a more versatile configuration object:
interface RetryConfig {
count?: number
delay?: number | ((error: any, retryCount: number) => ObservableInput<any>)
resetOnSuccess?: boolean
}
This variant lets you postpone the resubscription using a delay in milliseconds, or wait for another Observable to emit. The configuration also determines whether a successful emission resets the retry counter.
timeout
While on the subject of requests, the timeout operator is the inverse: it deliberately produces an error when nothing is emitted within N milliseconds. Used alongside retry or other operators, it becomes a valuable combination.
import { timeout } from 'rxjs';
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
timeout(3000)
);
}
catchError
The catchError operator deserves special attention. It often feels counterintuitive, but its flexibility is why. Here is the reasoning.
An Observable that emits an error is effectively terminated. It cannot emit further values anymore, its lifecycle is finished. To resume getting values, you must start over from a fresh subscription.
That is why catchError does not merely swap the for a new value. Instead, it transfers control to a different Observable, which then manages the continuation of data flow.
catchError(error => otherObservable)
Consider a scenario where the fallback involves a secondary HTTP call (if it were the same request, retry would be sufficient):
catchError(error => this.http.get(...))
If a rudimentary fallback is enough, you can simply return a primitive value. The previous error vanishes, the new value is passed along, and the stream completes right after. To do this, wrap the value using the of factory:
import { of } from 'rxjs';
catchError(error => of(value))
You can achieve the same result with an array literal instead of importing of:
catchError(error => [value]);
That trick works because RxJS operators that accept an Observable also handle arrays, promises, and other iterables. These sources are all classified as ObservableInput and converted internally before processing.
The next question becomes: where in the application should this logic live?
Subscribe
The most direct spot is within the subscribe callback, typically written in a Component. The pattern looks like this:
export class PostComponent {
getPosts() {
this.http.get<Post[]>('/api/post').subscribe({
next: posts => this.posts = posts,
error: e => this.error = e,
});
}
}
Here, error handling can be finely tuned, for instance, displaying different messages depending on the originating call or the specific page the user is on. In return, the syntax becomes fairly verbose, depending on how many distinct cases you want to cover.
Exactly the way it's usually understood: this only detects the misstep, it does not acknowledge or recover from it. To implement actual recovery, you need something like `catchError` placed upstream.
export class PostComponent {
getPosts() {
this.http.get<Post[]>('/api/post').pipe(
catchError(() => []),
).subscribe(posts => {
this.posts = posts;
});
}
}
Keep that distinction front and center: once catchError is in the chain, the subscribe callback for errors goes silent.
Services
When your application relies on Services—which it likely should—you have the option to handle errors at that layer, before they reach the Components. A basic implementation might look like this:
@Injectable({ providedIn: 'root' })
export class PostService {
http = inject(HttpClient);
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
catchError(() => []),
);
}
}
There's a notable drawback to this pattern, though: as mentioned earlier, the Component stays completely unaware that anything went wrong.
For most scenarios, I'd advise against this approach. As your application expands, you'll likely need to display different error messages depending on which page the user is on. With error handling confined to the Service, that flexibility disappears.
A better strategy is to use Services while still catching errors at the component level:
export class PostComponent {
getPosts() {
this.postService.getPosts().pipe(
catchError(() => []),
).subscribe(posts => {
this.posts = posts;
});
}
}
If you need to observe errors at the Service level and still pass them through to the Components, the tap operator can help—it lets you peek at errors without swallowing them:
@Injectable({ providedIn: 'root' })
export class PostService {
http = inject(HttpClient);
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
tap({
error: e => console.log(e)
}),
);
}
}
This isn't a pattern I'd reach for regularly, but in specific cases it might be exactly what you need.
Interceptors
For handling HTTP errors across the entire application, an Interceptor is your tool. Interceptors give you the ability to run logic before and after every HTTP request your app makes. The following example demonstrates the modern functional interceptor syntax:
function logInterceptor(
req: HttpRequest<any>,
next: HttpHandler
): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
tap({
error: e => console.log(e)
})
)
}
To make it available everywhere, register the interceptor globally:
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(
withInterceptors([logInterceptor])
)
]
})
One important caution: avoid catching the error outright, as that stops it from reaching the components. Instead, this is a good spot for tracking global state—like how many requests are in flight or the cumulative error count.
Still, there are errors you might want to suppress entirely from the user's view. The retry operator, for instance, lets you silently recover from a failed request without the user ever seeing it.
Interceptors shine particularly with 401 responses, which usually indicate that a user's session has expired. In such cases, you'd want to redirect them to the login page:
function logInterceptor(
req: HttpRequest<any>,
next: HttpHandler)
: Observable<HttpEvent<any>> {
router = inject(Router);
return next.handle(req).pipe(
tap({
error: e => {
if (e instanceof HttpErrorResponse && e.status === 401) {
this.router.navigateByUrl('/login');
}
}
})
)
}
Other frequent uses for interceptors include:
- Implementing a global retry strategy
- Creating a global request cache
- Refreshing authentication tokens
- Adding Headers to outgoing requests
- And many more...
Store
In a Store-based architecture, side-effects typically have their own home. For this example, I'll use NgRx.
The key difference with a Store is that your data lives there instead of within the Components. That makes catching errors inside an Effect perfectly acceptable, and you can even persist those errors directly into the Store for later use.
loadPosts$ = createEffect(() => this.actions$.pipe(
ofType(loadPosts),
switchMap(() => this.postService.getPosts().pipe(
map(posts => loadPostsSuccess(posts)),
catchError(e => [loadPostsError(e)])
))
));
Another option is to consolidate various error actions and handle them in a single, unified location:
handleAllErrors$ = createEffect(() => this.actions$.pipe(
ofType(loadPostsError, loadUsersError, loadTodosError),
...
);
This strategy isn't something I'd recommend for your entire app—you'd end up constantly modifying it with new actions. But for smaller feature areas or individual pages, it works quite well.
ErrorHandler
Finally, when your goal isn't to fix errors but simply to detect them, you can swap out Angular's default ErrorHandler with your own implementation:
import { ErrorHandler } from '@angular/core';
export class CustomErrorHandler implements ErrorHandler {
handleError(error) {
// Do what you want here, but throw it so that it's visible on the console!
throw new Error(error);
}
}
Then register it globally:
providers: [{
provide: ErrorHandler,
useClass: CustomErrorHandler
}]
This class captures every unhandled error in your application—not just HTTP failures but any exception. It's the ultimate fallback when there's nothing else you can do. Typically, it's used for logging, such as sending error reports to an analytics service. Naturally, if an error is already caught elsewhere (using catchError, for example), it will never make it to this handler.
Conclusions
- Treat errors as part of the normal flow of your program; handle them whenever possible
- Errors that the user needs to see are best caught at the component-level or store-level
- Use interceptors for HTTP errors that aren't specific to any page, or that users shouldn't see
- Use
ErrorHandlerfor unhandled errors—typically actual bugs
AccademiaDev: text-based web development courses!
My goal is to offer focused, useful content without all the extra fluff you often get in traditional books. Based on my experience as both a consultant and trainer, these interactive, online courses deliver practical knowledge through a mix of text, code samples, and quizzes—aiming for an effective and engaging way to learn.
Photo by Sarah Kilian on Unsplash


