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:
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.
@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. */
Q: You have an auth interceptor that adds JWT tokens to requests. The token expires, a request returns 401, and you need to refresh the token and retry the original request. Walk me through how you would implement this without causing a race condition when multiple requests fail simultaneously.
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.
Q: Explain the difference between switchMap, mergeMap, concatMap, and exhaustMap. For a form submission, which would you choose and why?
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.
Q: How would you implement client-side caching for HTTP responses in Angular? What are the invalidation strategies?
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.