"Error Handling — Building Apps That Don't Break"
👋 Welcome to Chapter 6!
You've built beautiful features using Observables. But what happens when the internet goes down? What if the API server crashes? What if the user has no signal?
In the real world, things go wrong. All the time.
This chapter teaches you how to handle errors gracefully — so your app stays alive even when everything around it falls apart.
💥 What Happens When an Observable Errors?
When an Observable encounters an error, it:
- Emits the error to the
errorhandler in subscribe - Stops completely — no more values, ever
this.http.get('/api/users').subscribe({
next: (users) => { this.users = users; },
error: (err) => {
// ❌ Error happened here
// The Observable is now dead. It will never emit again.
console.error(err);
},
complete: () => {
// This will NOT be called if there was an error
}
});
The problem? If you handle errors only in subscribe, you can't retry, you can't provide fallback data, and you can't do complex error logic.
That's where catchError and retry come in.
🥅 catchError — Catch it Before it Reaches subscribe
catchError goes inside pipe() and intercepts errors before they reach subscribe. It gives you a chance to:
- Provide fallback data so the app keeps working
- Rethrow the error if you want subscribe to handle it
- Log the error and recover
import { catchError } from 'rxjs/operators';
import { of, EMPTY, throwError } from 'rxjs';
this.http.get<User[]>('/api/users')
.pipe(
catchError(error => {
console.error('API failed:', error);
// Option 1: Return fallback data — app keeps working
return of([]); // Return empty array
// Option 2: Return EMPTY — complete without any value
// return EMPTY;
// Option 3: Re-throw — let subscribe handle it
// return throwError(() => error);
})
)
.subscribe(users => {
// If API failed, users = [] (empty array from fallback)
// App still works!
this.users = users;
});
🏗️ Real-World Pattern — Service with Error Handling
user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class UserService {
constructor(private http: HttpClient) {}
getUsers(): Observable<User[]> {
return this.http.get<User[]>('/api/users')
.pipe(
catchError(this.handleError)
);
}
getUserById(id: number): Observable<User> {
return this.http.get<User>(`/api/users/${id}`)
.pipe(
catchError(this.handleError)
);
}
// Centralized error handler — reused across all methods
private handleError(error: HttpErrorResponse): Observable<never> {
let userFriendlyMessage = 'Something went wrong. Please try again.';
if (error.status === 0) {
// Network error (no internet, server unreachable)
userFriendlyMessage = 'No internet connection. Please check your network.';
} else if (error.status === 404) {
userFriendlyMessage = 'The requested data was not found.';
} else if (error.status === 401) {
userFriendlyMessage = 'You are not authorized. Please log in.';
} else if (error.status === 403) {
userFriendlyMessage = 'Access denied. You do not have permission.';
} else if (error.status === 500) {
userFriendlyMessage = 'Server error. Please try again later.';
}
// Log technical details for developers
console.error(`HTTP Error ${error.status}:`, error.message);
// Re-throw with user-friendly message
return throwError(() => new Error(userFriendlyMessage));
}
}
users.component.ts
@Component({
template: `
<!-- Loading state -->
<div *ngIf="isLoading" class="loading">
<div class="spinner"></div>
Loading users...
</div>
<!-- Error state -->
<div *ngIf="errorMessage" class="error-card">
<p>⚠️ {{ errorMessage }}</p>
<button (click)="loadUsers()">Try Again 🔄</button>
</div>
<!-- Success state -->
<div *ngIf="!isLoading && !errorMessage">
<div *ngFor="let user of users" class="user-card">
{{ user.name }}
</div>
</div>
`
})
export class UsersComponent implements OnInit {
users: User[] = [];
isLoading = false;
errorMessage = '';
constructor(private userService: UserService) {}
ngOnInit(): void {
this.loadUsers();
}
loadUsers(): void {
this.isLoading = true;
this.errorMessage = '';
this.userService.getUsers().subscribe({
next: (users) => {
this.users = users;
this.isLoading = false;
},
error: (err) => {
this.errorMessage = err.message;
this.isLoading = false;
}
});
}
}
🔄 retry() — Automatically Try Again
Sometimes errors are temporary — a brief network hiccup, a server that's momentarily busy. retry() tells the Observable to try again automatically.
import { retry } from 'rxjs/operators';
this.http.get('/api/data')
.pipe(
retry(3), // Try up to 3 times before giving up
catchError(err => {
// Only reaches here if all 3 retries fail
return of({ fallback: true, data: [] });
})
)
.subscribe(data => this.data = data);
Timeline:
Attempt 1 → ❌ fails
Attempt 2 → ❌ fails
Attempt 3 → ❌ fails
→ catchError takes over → returns fallback data
⏱️ retryWhen / retry with Delay — Smart Retrying
Retrying immediately might not help. Often you want to wait a bit before retrying. In modern RxJS, you can pass a config to retry:
import { retry, catchError } from 'rxjs/operators';
import { timer } from 'rxjs';
this.http.get('/api/data')
.pipe(
retry({
count: 3, // Max 3 retries
delay: 2000 // Wait 2 seconds between each retry
}),
catchError(err => {
console.error('Failed after 3 retries:', err);
return of([]);
})
)
.subscribe(data => this.data = data);
Exponential Backoff (Advanced) — Wait longer each time
import { retry } from 'rxjs/operators';
import { timer } from 'rxjs';
this.http.get('/api/critical-data')
.pipe(
retry({
count: 4,
delay: (error, retryCount) => {
// Wait 1s, then 2s, then 4s, then 8s (exponential)
const waitMs = Math.pow(2, retryCount) * 1000;
console.log(`Retry ${retryCount} in ${waitMs}ms`);
return timer(waitMs);
}
}),
catchError(err => of({ error: true, message: err.message }))
)
.subscribe(data => this.data = data);
🔁 finalize() — Always Runs, Error or Not
finalize() runs when an Observable completes — whether successfully or with an error. It's like finally in try/catch.
Perfect for: hiding loading spinners
import { finalize } from 'rxjs/operators';
this.isLoading = true;
this.http.get('/api/data')
.pipe(
catchError(err => {
this.error = err.message;
return EMPTY;
}),
finalize(() => {
// Always runs — success or failure!
this.isLoading = false;
console.log('Request complete (success or error)');
})
)
.subscribe(data => this.data = data);
No more duplicating this.isLoading = false in both next and error handlers! 🎉
🌐 Global HTTP Error Handling with Interceptors
In a real app, you don't want to handle auth errors (401) in every single component. Use an HTTP Interceptor to handle errors globally:
auth.interceptor.ts
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Router } from '@angular/router';
import { AuthService } from './auth.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private router: Router, private authService: AuthService) {}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Add auth token to every request
const token = localStorage.getItem('token');
if (token) {
request = request.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next.handle(request).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Token expired — log out and redirect to login
this.authService.logout();
this.router.navigate(['/login']);
}
if (error.status === 403) {
// No permission — redirect to forbidden page
this.router.navigate(['/forbidden']);
}
if (error.status >= 500) {
// Server error — show a global toast notification
this.notificationService.showError('Server error. Please try again later.');
}
return throwError(() => error);
})
);
}
}
Register it in app.module.ts
@NgModule({
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true
}
]
})
export class AppModule {}
Now ALL HTTP errors in your entire app are handled in one place! 🎯
🎨 Real-World Complete Example — Product Page with Full Error Handling
import { Component, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { catchError, retry, finalize, tap } from 'rxjs/operators';
interface PageState {
products: Product[];
isLoading: boolean;
error: string | null;
}
@Component({
selector: 'app-product-page',
template: `
<ng-container *ngIf="state$ | async as state">
<!-- Loading -->
<div *ngIf="state.isLoading" class="loading-container">
<div class="spinner"></div>
<p>Loading products...</p>
</div>
<!-- Error with retry button -->
<div *ngIf="state.error && !state.isLoading" class="error-container">
<h3>😕 Oops! Something went wrong</h3>
<p>{{ state.error }}</p>
<button (click)="reload()">🔄 Try Again</button>
</div>
<!-- Empty state -->
<div *ngIf="!state.isLoading && !state.error && state.products.length === 0">
<p>No products found.</p>
</div>
<!-- Product list -->
<div *ngIf="!state.isLoading && !state.error && state.products.length > 0">
<div *ngFor="let product of state.products" class="product-card">
<h3>{{ product.name }}</h3>
<p>৳{{ product.price }}</p>
</div>
</div>
</ng-container>
`
})
export class ProductPageComponent implements OnInit {
state$!: Observable<PageState>;
constructor(private productService: ProductService) {}
ngOnInit(): void {
this.load();
}
reload(): void {
this.load();
}
private load(): void {
// Start with loading state
const initialState: PageState = {
products: [],
isLoading: true,
error: null
};
this.state$ = this.productService.getProducts()
.pipe(
retry(2), // Try 2 more times on failure
tap(products => {
// Could log analytics here
console.log(`Loaded ${products.length} products`);
}),
map(products => ({
products,
isLoading: false,
error: null
} as PageState)),
catchError(err => of({
products: [],
isLoading: false,
error: err.message || 'Failed to load products'
} as PageState)),
startWith(initialState)
);
}
}
This component handles every possible state: loading, error, empty, and success — all through a single Observable stream! 🏆
🧠 Chapter 6 Summary — What You Learned
- When an Observable errors, it stops permanently — no more values
-
catchError()intercepts errors in the pipe and lets you provide fallback data or rethrow -
retry(n)automatically retries the Observable up to N times before giving up - Use
retrywith a delay config for production-grade retry logic with exponential backoff -
finalize()always runs whether the Observable succeeded or failed — perfect for hiding loading spinners - Use HTTP Interceptors to handle common errors (401, 500) globally for the whole app
- Pattern:
retry→catchError→finalize— this chain covers almost every real-world case
📚 Coming Up in Chapter 7...
We've handled errors. Now let's talk about combining multiple Observables — what if you need data from TWO different APIs at the same time?
Chapter 7 covers forkJoin, combineLatest, zip, and withLatestFrom — the combination operators!
See you in Chapter 7! 🚀
💌 RxJS Deep Dive Newsletter Series | Chapter 6 of 10
Follow me on : Github Linkedin Threads Youtube Channel
Top comments (0)