Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Angular HTTP Client

Module Overview

Estimated Time: 4-5 hours | Difficulty: Intermediate | Prerequisites: Module 7
Angular’s HttpClient provides a powerful API for making HTTP requests while RxJS enables reactive data handling. Together they form the foundation of Angular’s data layer. What You’ll Learn:
  • HttpClient setup and configuration
  • Making HTTP requests (GET, POST, PUT, DELETE)
  • HTTP interceptors for cross-cutting concerns
  • Error handling strategies
  • RxJS operators for data transformation
  • Caching and request management

HttpClient Setup

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withFetch(),        // Use fetch API instead of XMLHttpRequest
      withInterceptors([  // Add interceptors
        authInterceptor,
        loggingInterceptor,
        errorInterceptor
      ])
    )
  ]
};

Making HTTP Requests

Basic CRUD Operations

import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

export interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
}

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);
  private apiUrl = '/api/products';
  
  // GET all products
  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.apiUrl);
  }
  
  // GET with query parameters
  getProductsPaginated(
    page: number, 
    pageSize: number, 
    category?: string
  ): Observable<PaginatedResponse<Product>> {
    let params = new HttpParams()
      .set('page', page.toString())
      .set('pageSize', pageSize.toString());
    
    if (category) {
      params = params.set('category', category);
    }
    
    return this.http.get<PaginatedResponse<Product>>(this.apiUrl, { params });
  }
  
  // GET single product
  getProduct(id: number): Observable<Product> {
    return this.http.get<Product>(`${this.apiUrl}/${id}`);
  }
  
  // POST - create new product
  createProduct(product: Omit<Product, 'id'>): Observable<Product> {
    return this.http.post<Product>(this.apiUrl, product);
  }
  
  // PUT - update product
  updateProduct(id: number, product: Partial<Product>): Observable<Product> {
    return this.http.put<Product>(`${this.apiUrl}/${id}`, product);
  }
  
  // PATCH - partial update
  patchProduct(id: number, updates: Partial<Product>): Observable<Product> {
    return this.http.patch<Product>(`${this.apiUrl}/${id}`, updates);
  }
  
  // DELETE
  deleteProduct(id: number): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/${id}`);
  }
}

Request Options

// Custom headers
const headers = new HttpHeaders({
  'Content-Type': 'application/json',
  'X-Custom-Header': 'value'
});

this.http.get<Product[]>(this.apiUrl, { headers });

// Observe response (headers, status, etc.)
this.http.get<Product[]>(this.apiUrl, { observe: 'response' })
  .subscribe(response => {
    console.log('Status:', response.status);
    console.log('Headers:', response.headers.get('X-Total-Count'));
    console.log('Body:', response.body);
  });

// Get response as text
this.http.get(this.apiUrl, { responseType: 'text' });

// Get response as blob (for files)
this.http.get(this.apiUrl + '/export', { responseType: 'blob' });

// Track upload/download progress
this.http.post(this.apiUrl, formData, {
  reportProgress: true,
  observe: 'events'
}).subscribe(event => {
  if (event.type === HttpEventType.UploadProgress) {
    const progress = Math.round(100 * event.loaded / event.total!);
    console.log(`Upload: ${progress}%`);
  }
});

HTTP Interceptors

Interceptors handle cross-cutting concerns — tasks that apply to every HTTP request or response, regardless of which component or service initiated them. Without interceptors, you would need to manually add auth tokens to every API call, log every request individually, and handle 401 errors in every service method. Interceptors let you write that logic once and have it apply globally. Think of interceptors like airport security checkpoints: every passenger (request) passes through the same screening process regardless of their destination.
┌─────────────────────────────────────────────────────────────────────────┐
│                    Interceptor Chain                                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   Request Flow:                                                          │
│   ┌────────────┐     ┌──────────────┐     ┌──────────────┐              │
│   │  Component │ ──▶ │ Auth Inject. │ ──▶ │ Log Inject.  │ ──▶ Server  │
│   └────────────┘     └──────────────┘     └──────────────┘              │
│                                                                          │
│   Response Flow:                                                         │
│   ┌────────────┐     ┌──────────────┐     ┌──────────────┐              │
│   │  Component │ ◀── │ Auth Inject. │ ◀── │ Log Inject.  │ ◀── Server  │
│   └────────────┘     └──────────────┘     └──────────────┘              │
│                                                                          │
│   Common Use Cases:                                                      │
│   • Add auth tokens to requests                                          │
│   • Log requests/responses                                               │
│   • Handle errors globally                                               │
│   • Add caching                                                          │
│   • Transform responses                                                  │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Auth Interceptor

// interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const token = authService.getToken();
  
  // Skip auth for public endpoints
  if (req.url.includes('/public/')) {
    return next(req);
  }
  
  if (token) {
    const authReq = req.clone({
      headers: req.headers.set('Authorization', `Bearer ${token}`)
    });
    return next(authReq);
  }
  
  return next(req);
};

Error Interceptor

// interceptors/error.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);
  const toastService = inject(ToastService);
  
  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      let errorMessage = 'An error occurred';
      
      if (error.status === 0) {
        errorMessage = 'Network error. Please check your connection.';
      } else if (error.status === 401) {
        errorMessage = 'Session expired. Please login again.';
        router.navigate(['/login']);
      } else if (error.status === 403) {
        errorMessage = 'You do not have permission to access this resource.';
      } else if (error.status === 404) {
        errorMessage = 'Resource not found.';
      } else if (error.status >= 500) {
        errorMessage = 'Server error. Please try again later.';
      } else if (error.error?.message) {
        errorMessage = error.error.message;
      }
      
      toastService.showError(errorMessage);
      
      return throwError(() => error);
    })
  );
};

Logging Interceptor

// interceptors/logging.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { tap, finalize } from 'rxjs';

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const startTime = Date.now();
  
  console.log(`🚀 ${req.method} ${req.url}`);
  
  return next(req).pipe(
    tap({
      next: (event) => {
        if (event.type === HttpEventType.Response) {
          console.log(`✅ ${req.url} - ${event.status}`);
        }
      },
      error: (error) => {
        console.error(`❌ ${req.url} - ${error.status}`);
      }
    }),
    finalize(() => {
      const duration = Date.now() - startTime;
      console.log(`⏱️ ${req.url} took ${duration}ms`);
    })
  );
};

Caching Interceptor

// interceptors/cache.interceptor.ts
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of, tap } from 'rxjs';

const cache = new Map<string, HttpResponse<unknown>>();

export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
  // Only cache GET requests
  if (req.method !== 'GET') {
    return next(req);
  }
  
  // Check if request should be cached
  if (!req.headers.has('X-Cache')) {
    return next(req);
  }
  
  const cacheKey = req.urlWithParams;
  const cachedResponse = cache.get(cacheKey);
  
  if (cachedResponse) {
    console.log(`📦 Cache hit: ${cacheKey}`);
    return of(cachedResponse.clone());
  }
  
  return next(req).pipe(
    tap(event => {
      if (event instanceof HttpResponse) {
        console.log(`💾 Caching: ${cacheKey}`);
        cache.set(cacheKey, event.clone());
      }
    })
  );
};

RxJS Operators

Common Operators for HTTP

import { 
  map, filter, tap, catchError, retry, retryWhen,
  switchMap, mergeMap, concatMap, exhaustMap,
  debounceTime, distinctUntilChanged, throttleTime,
  takeUntil, take, first, share, shareReplay
} from 'rxjs/operators';
import { of, throwError, timer, Subject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class DataService {
  private http = inject(HttpClient);
  
  // Transform data
  getUserNames(): Observable<string[]> {
    return this.http.get<User[]>('/api/users').pipe(
      map(users => users.map(u => u.name))
    );
  }
  
  // Filter results
  getActiveUsers(): Observable<User[]> {
    return this.http.get<User[]>('/api/users').pipe(
      map(users => users.filter(u => u.active))
    );
  }
  
  // Side effects (logging, analytics)
  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>('/api/products').pipe(
      tap(products => console.log(`Fetched ${products.length} products`)),
      tap(products => this.analytics.track('products_fetched', { count: products.length }))
    );
  }
  
  // Retry on failure
  getDataWithRetry(): Observable<Data> {
    return this.http.get<Data>('/api/data').pipe(
      retry(3),  // Retry 3 times on failure
      catchError(error => {
        console.error('All retries failed');
        return of({ fallback: true } as Data);
      })
    );
  }
  
  // Exponential backoff retry
  getDataWithBackoff(): Observable<Data> {
    return this.http.get<Data>('/api/data').pipe(
      retryWhen(errors =>
        errors.pipe(
          mergeMap((error, index) => {
            const retryAttempt = index + 1;
            if (retryAttempt > 3) {
              return throwError(() => error);
            }
            console.log(`Retry attempt ${retryAttempt}: waiting ${retryAttempt * 1000}ms`);
            return timer(retryAttempt * 1000);
          })
        )
      )
    );
  }
  
  // Share results (avoid duplicate HTTP calls)
  private products$ = this.http.get<Product[]>('/api/products').pipe(
    shareReplay(1)  // Cache and replay last value
  );
  
  getProducts(): Observable<Product[]> {
    return this.products$;  // Multiple subscribers = 1 HTTP call
  }
}

SwitchMap vs MergeMap vs ConcatMap vs ExhaustMap

@Component({
  selector: 'app-search',
  template: `
    <input (input)="onSearch($event)" placeholder="Search..." />
    <ul>
      @for (result of results(); track result.id) {
        <li>{{ result.name }}</li>
      }
    </ul>
  `
})
export class SearchComponent {
  private searchSubject = new Subject<string>();
  results = signal<SearchResult[]>([]);
  
  constructor() {
    // switchMap: Cancel previous request when new one comes
    // Best for search/autocomplete
    this.searchSubject.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(term => this.searchService.search(term))
    ).subscribe(results => this.results.set(results));
  }
  
  onSearch(event: Event) {
    const term = (event.target as HTMLInputElement).value;
    this.searchSubject.next(term);
  }
}

/*
 * CHOOSING THE RIGHT FLATTENING OPERATOR
 * This is one of the most important RxJS decisions you will make.
 * Pick the wrong one and you get race conditions, duplicate requests,
 * or unresponsive UIs.
 *
 * switchMap: Cancel previous, keep latest only.
 *   USE FOR: Search/autocomplete, route parameter changes, any case
 *   where only the most recent request matters.
 *   User types: a -> ab -> abc
 *   Requests:   a(cancelled) -> ab(cancelled) -> abc(completed)
 *
 * mergeMap: All requests run in parallel, complete in any order.
 *   USE FOR: Fire-and-forget operations like analytics events or
 *   logging where every event matters and order does not.
 *
 * concatMap: Requests queue up and run one after another.
 *   USE FOR: Operations where order matters, like sequential file
 *   uploads or database writes that depend on previous results.
 *
 * exhaustMap: Ignore new requests while one is in progress.
 *   USE FOR: Form submissions, login buttons -- prevents the
 *   classic "user clicks Submit 5 times" bug.
 */

Error Handling Patterns

Service Level Error Handling

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  
  getUser(id: number): Observable<User> {
    return this.http.get<User>(`/api/users/${id}`).pipe(
      catchError(this.handleError<User>('getUser'))
    );
  }
  
  private handleError<T>(operation = 'operation') {
    return (error: HttpErrorResponse): Observable<T> => {
      console.error(`${operation} failed:`, error);
      
      // Rethrow for component to handle
      // return throwError(() => new Error(`${operation} failed: ${error.message}`));
      
      // Or return safe fallback
      return of(undefined as T);
    };
  }
}

Component Level Error Handling

@Component({
  template: `
    @if (loading()) {
      <app-spinner />
    } @else if (error()) {
      <app-error 
        [message]="error()" 
        (retry)="loadData()" 
      />
    } @else {
      <app-user-list [users]="users()" />
    }
  `
})
export class UsersComponent implements OnInit {
  private userService = inject(UserService);
  
  users = signal<User[]>([]);
  loading = signal(false);
  error = signal<string | null>(null);
  
  ngOnInit() {
    this.loadData();
  }
  
  loadData() {
    this.loading.set(true);
    this.error.set(null);
    
    this.userService.getUsers().subscribe({
      next: (users) => {
        this.users.set(users);
        this.loading.set(false);
      },
      error: (err) => {
        this.error.set('Failed to load users. Please try again.');
        this.loading.set(false);
      }
    });
  }
}

Cancellation and Cleanup

@Component({...})
export class SearchComponent implements OnDestroy {
  private destroy$ = new Subject<void>();
  private searchTerms = new Subject<string>();
  
  results = signal<Product[]>([]);
  
  constructor() {
    this.searchTerms.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(term => this.productService.search(term)),
      takeUntil(this.destroy$)  // Auto-unsubscribe on destroy
    ).subscribe(results => this.results.set(results));
  }
  
  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
  
  search(term: string) {
    this.searchTerms.next(term);
  }
}

// Modern alternative using DestroyRef
@Component({...})
export class ModernSearchComponent {
  private destroyRef = inject(DestroyRef);
  
  constructor() {
    this.searchTerms.pipe(
      debounceTime(300),
      switchMap(term => this.productService.search(term)),
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(results => this.results.set(results));
  }
}

Signals with HTTP

Using toSignal

import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  template: `
    <ul>
      @for (product of products(); track product.id) {
        <li>{{ product.name }}</li>
      }
    </ul>
  `
})
export class ProductsComponent {
  private productService = inject(ProductService);
  
  // Convert Observable to Signal
  products = toSignal(this.productService.getProducts(), {
    initialValue: []
  });
}

Reactive Data Loading

@Component({
  template: `
    <select (change)="setCategory($event)">
      <option value="">All</option>
      <option value="electronics">Electronics</option>
      <option value="clothing">Clothing</option>
    </select>
    
    @if (loading()) {
      <p>Loading...</p>
    } @else {
      @for (product of products(); track product.id) {
        <app-product-card [product]="product" />
      }
    }
  `
})
export class ProductListComponent {
  private productService = inject(ProductService);
  
  category = signal<string>('');
  loading = signal(false);
  
  products = toSignal(
    toObservable(this.category).pipe(
      tap(() => this.loading.set(true)),
      switchMap(cat => this.productService.getByCategory(cat)),
      tap(() => this.loading.set(false))
    ),
    { initialValue: [] }
  );
  
  setCategory(event: Event) {
    const value = (event.target as HTMLSelectElement).value;
    this.category.set(value);
  }
}

Practice Exercise

Exercise: Build a Data Table with Pagination and Search

Create a data table component that:
  1. Fetches paginated data from API
  2. Has search with debounce
  3. Supports sorting
  4. Shows loading and error states
  5. Uses proper RxJS operators
interface TableState {
  page: number;
  pageSize: number;
  search: string;
  sortBy: string;
  sortDir: 'asc' | 'desc';
}

@Component({
  selector: 'app-data-table',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <div class="table-controls">
      <input 
        [formControl]="searchControl"
        placeholder="Search..."
      />
      
      <select (change)="setPageSize($event)">
        <option value="10">10 per page</option>
        <option value="25">25 per page</option>
        <option value="50">50 per page</option>
      </select>
    </div>
    
    @if (loading()) {
      <div class="loading">Loading...</div>
    } @else if (error()) {
      <div class="error">
        {{ error() }}
        <button (click)="refresh()">Retry</button>
      </div>
    } @else {
      <table>
        <thead>
          <tr>
            <th (click)="sort('name')">
              Name {{ sortIndicator('name') }}
            </th>
            <th (click)="sort('email')">
              Email {{ sortIndicator('email') }}
            </th>
            <th (click)="sort('createdAt')">
              Created {{ sortIndicator('createdAt') }}
            </th>
          </tr>
        </thead>
        <tbody>
          @for (item of data()?.data; track item.id) {
            <tr>
              <td>{{ item.name }}</td>
              <td>{{ item.email }}</td>
              <td>{{ item.createdAt | date }}</td>
            </tr>
          } @empty {
            <tr>
              <td colspan="3">No results found</td>
            </tr>
          }
        </tbody>
      </table>
      
      <div class="pagination">
        <button 
          [disabled]="state().page === 1"
          (click)="goToPage(state().page - 1)"
        >
          Previous
        </button>
        
        <span>
          Page {{ state().page }} of {{ totalPages() }}
        </span>
        
        <button 
          [disabled]="state().page >= totalPages()"
          (click)="goToPage(state().page + 1)"
        >
          Next
        </button>
      </div>
    }
  `
})
export class DataTableComponent {
  private userService = inject(UserService);
  
  searchControl = new FormControl('');
  
  state = signal<TableState>({
    page: 1,
    pageSize: 10,
    search: '',
    sortBy: 'name',
    sortDir: 'asc'
  });
  
  loading = signal(false);
  error = signal<string | null>(null);
  
  private refresh$ = new Subject<void>();
  
  data = toSignal(
    merge(
      toObservable(this.state),
      this.refresh$.pipe(map(() => this.state()))
    ).pipe(
      tap(() => {
        this.loading.set(true);
        this.error.set(null);
      }),
      switchMap(state => 
        this.userService.getUsers(state).pipe(
          catchError(err => {
            this.error.set('Failed to load data');
            return of(null);
          })
        )
      ),
      tap(() => this.loading.set(false))
    )
  );
  
  totalPages = computed(() => {
    const d = this.data();
    if (!d) return 0;
    return Math.ceil(d.total / this.state().pageSize);
  });
  
  constructor() {
    // Debounced search
    this.searchControl.valueChanges.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      takeUntilDestroyed()
    ).subscribe(search => {
      this.state.update(s => ({ ...s, search: search || '', page: 1 }));
    });
  }
  
  sort(column: string) {
    this.state.update(s => ({
      ...s,
      sortBy: column,
      sortDir: s.sortBy === column && s.sortDir === 'asc' ? 'desc' : 'asc',
      page: 1
    }));
  }
  
  sortIndicator(column: string): string {
    const s = this.state();
    if (s.sortBy !== column) return '';
    return s.sortDir === 'asc' ? '▲' : '▼';
  }
  
  goToPage(page: number) {
    this.state.update(s => ({ ...s, page }));
  }
  
  setPageSize(event: Event) {
    const pageSize = +(event.target as HTMLSelectElement).value;
    this.state.update(s => ({ ...s, pageSize, page: 1 }));
  }
  
  refresh() {
    this.refresh$.next();
  }
}

Summary

1

HttpClient

Use provideHttpClient() and inject HttpClient for API calls
2

Interceptors

Handle auth, logging, errors, and caching globally
3

RxJS Operators

Use switchMap for search, catchError for errors, shareReplay for caching
4

Error Handling

Handle errors at service and component levels with retry logic
5

Signals Integration

Use toSignal/toObservable for seamless reactive integration

Interview Deep-Dive

Strong Answer: The naive approach — catching the 401, calling refreshToken, then retrying — breaks when three requests fail at the same time. All three independently trigger refreshToken, causing three refresh calls where only one is needed.The fix is a shared refresh observable. I maintain a BehaviorSubject or a class-level variable that tracks whether a refresh is already in progress. When the first 401 arrives, the interceptor starts the refresh and stores the observable (using shareReplay(1) so late subscribers get the same result). When the second and third 401s arrive, they check the in-progress flag and subscribe to the existing refresh observable instead of starting a new one.The implementation: the interceptor catches 401 errors. If no refresh is in progress, it sets a flag, calls authService.refreshToken().pipe(shareReplay(1)), stores the result, and then switchMaps to retry the original request with the new token. If a refresh IS in progress, it waits for the existing refresh to complete, then retries with the new token. After the refresh completes (success or failure), it clears the flag.Edge case: if the refresh token itself is expired, the refresh call returns 401. You must NOT retry the refresh in an infinite loop. I detect this by checking if the failing request IS the refresh endpoint, and if so, I log out the user instead of retrying.Follow-up: How do you handle requests that were queued while the token was refreshing? Answer: Each 401 request’s handler subscribes to the shared refresh observable. When it emits the new token, all waiting handlers simultaneously retry their original requests with the updated token. Because they all share the same observable via shareReplay, they all proceed at once after a single refresh call completes. The requests queue themselves naturally via the RxJS subscription mechanism — no manual queue needed.
Strong Answer: These four operators all map a source emission to an inner observable, but they differ in how they handle overlapping inner subscriptions.switchMap cancels the previous inner observable when a new source emission arrives. Use for search/autocomplete where only the latest result matters. mergeMap subscribes to all inner observables in parallel with no cancellation. Use for fire-and-forget operations like analytics events. concatMap queues inner observables and processes them sequentially. Use when order matters, like sequential file uploads. exhaustMap ignores new source emissions while an inner observable is in progress. Use for form submissions.For form submissions, exhaustMap is the right choice. Here is why: if the user clicks “Submit” three times quickly, switchMap would cancel the first two submissions (potentially leaving the server in an inconsistent state with a half-processed order). mergeMap would send three identical submissions in parallel (creating three orders). concatMap would queue all three and process them sequentially (still creating three orders, just slower). exhaustMap ignores the second and third clicks because the first submission is still in progress. Exactly one submission goes through.The common mistake I see is developers using switchMap for submissions because they are used to it from search. switchMap for POST requests is dangerous — the cancelled request might have already been processed server-side, but the client discards the response.Follow-up: What if you need to show a “submitting…” state during the exhaustMap? Answer: I use a signal isSubmitting = signal(false), set it to true before the exhaustMap inner observable, and false in a finalize operator. Because exhaustMap ignores new emissions during processing, the button can be disabled based on isSubmitting(), providing visual feedback that matches the actual behavior. Alternatively, I disable the submit button with [disabled]=“isSubmitting()” as a UX-level protection on top of the exhaustMap-level protection.
Strong Answer: There are three levels of HTTP caching in Angular. The simplest is shareReplay(1) on the service’s observable — multiple subscribers share one HTTP call, and late subscribers get the cached result. This is a per-session, in-memory cache.For more control, I implement a caching interceptor that checks a Map before forwarding requests. GET requests are cached by URL (including query params). Non-GET requests (POST, PUT, DELETE) invalidate related cache entries. The interceptor can also respect custom headers (like X-Cache-TTL) to control per-endpoint cache duration.For persistent caching across sessions, I use the service worker’s caching strategy (via Angular PWA) or manually cache to IndexedDB. The service worker approach is declarative — you configure it in ngsw-config.json — but it caches at the HTTP level without awareness of your application logic.Invalidation strategies: time-based (TTL — cache expires after N seconds), event-based (invalidate the users cache when a user is created/updated), and manual (expose a cache.clear() method for pull-to-refresh). In practice, I combine all three: a 5-minute TTL for list endpoints, immediate invalidation on mutations, and a manual refresh button.The gotcha with shareReplay: if the source observable errors, shareReplay replays the error to all new subscribers. You need catchError before shareReplay, or use a pattern that retries on error and only caches successes.Follow-up: How does Angular’s built-in HTTP transfer cache (for SSR) relate to this? Answer: Angular’s withHttpTransferCacheOptions in provideClientHydration automatically transfers HTTP responses made during server-side rendering to the client. The client-side HttpClient checks the transfer state before making a network call. This is a one-time, SSR-to-client cache — it prevents duplicate requests during hydration but does not provide ongoing caching. For that, you still need the interceptor-based or shareReplay approach.

Next Steps

Next: RxJS Deep Dive

Master advanced RxJS patterns for complex async scenarios