This episode covers API communication and caching: HTTP interceptors for centralized error handling, caching strategies for API responses, state transfer and server-side rendering cache, and optimizing network request performance.

In episode 8 you learned the HTTP basics. Now it's time to make your API communication professional: centralized error handling, smart caching, and lightweight requests.
Episode 14 covers HTTP interceptors for centralized error handling, caching strategies for API responses, state transfer and SSR cache, and optimizing network request performance. This is the bridge from an application that works to an application that's resilient.
An interceptor catches every HTTP request and response, making it the ideal place for cross-cutting logic like error handling. The functional form has been available since Angular 17:
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { NotifService } from './notif.service';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const notif = inject(NotifService);
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401) {
notif.tampil('Sesi habis, silakan login kembali', 'error');
} else if (err.status >= 500) {
notif.tampil('Terjadi kesalahan server', 'error');
}
console.error(`[${err.status}] ${req.method} ${req.url}`);
return throwError(() => err);
}),
);
};errorInterceptor handles errors from all requests in one place: status 401 tells the user the session has expired, status 5xx shows a server message. No more try-catch in every service — just register the interceptor with provideHttpClient(withInterceptors([errorInterceptor])).
Interceptors run in registration order for requests and in reverse for responses. The auth interceptor should be registered before the error interceptor so requests always carry the token when errors are computed.
Caching prevents repeated requests for the same data. The simplest RxJS approach is shareReplay as in episode 8, but you need a refresh policy:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, shareReplay, tap, timer, switchMap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class KatalogService {
private readonly http = inject(HttpClient);
private cache$: Observable<Produk[]> | null = null;
private readonly ttl = 300000;
listar(): Observable<Produk[]> {
if (!this.cache$) {
this.cache$ = this.http.get<Produk[]>('/api/produk').pipe(
shareReplay(1),
switchMap((data) =>
timer(this.ttl).pipe(switchMap(() => this.cache$ = null)),
),
);
}
return this.cache$;
}
}After five minutes (TTL of 300000 milliseconds), cache$ is set to null so the next request runs again. This strikes a balance between responsiveness and data freshness.
A cache must be invalidatable when data changes. A common method: an invalidate() method that sets cache$ = null, called after create, update, or delete operations. For frequently changing data, consider a tag- or key-based cache backed by a dedicated library.
When a page is rendered on the server (SSR), the same data can be transferred directly to the browser so the client doesn't need to request it again. Angular provides TransferState:
import { Injectable, inject, TransferState, makeStateKey } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { of, tap } from 'rxjs';
const PRODUK_KEY = makeStateKey<Produk[]>('produk-lengkap');
@Injectable({ providedIn: 'root' })
export class ProdukService {
private readonly http = inject(HttpClient);
private readonly state = inject(TransferState);
listar() {
const cached = this.state.get(PRODUK_KEY, null);
if (cached) {
return of(cached);
}
return this.http.get<Produk[]>('/api/produk').pipe(
tap((data) => this.state.set(PRODUK_KEY, data)),
);
}
}On the server, data is fetched and stored in TransferState. In the browser, the value is read directly from state, so there's no second request. This eliminates the delay and reduces API load for the same page.
The SSR render output itself can be cached on a CDN or server. Public pages with rarely changing data can be stored as static cache with a TTL, while personalized pages are still rendered per request.
Reduce the number of round-trips: combine independent requests with forkJoin, and cancel requests that are no longer relevant with switchMap. Send smaller payloads — request only the fields you need via query params or sparse fields.
import { forkJoin } from 'rxjs';
forkJoin({
profil: this.http.get<Profil>('/api/profil'),
order: this.http.get<Order[]>('/api/order?limit=10'),
}).subscribe(({ profil, order }) => {
this.profil.set(profil);
this.order.set(order);
});forkJoin waits for all requests to finish, then returns a single combined object — one subscription for two parallel requests. This cuts the waiting time compared to loading them one by one.
Always measure: monitor the number of requests per page, payload size, and response time on slow networks. Use HttpClient with withFetch for fetch API performance, and inspect the network waterfall in DevTools to find requests that are wasteful or sequential when they could be parallel.
Key takeaways:
shareReplay plus a TTL gives you simple cache with automatic refresh.TransferState moves data from server to browser so SSR has no double requests.forkJoin and measure performance regularly.In the next episode, episode 15, we'll cover performance optimization — profiling with Angular DevTools, the Default versus OnPush change detection strategies, trackBy and lazy loading for code splitting, and bundle analysis and production optimization.