Angular ships with a rich set of built-in capabilities, yet many of them remain underused. Developers often rely on a handful of familiar APIs without exploring the full breadth of what the framework offers.
Drawing on the style of @armandotrue and the popular series Superpowers with Directives and Dependency Injection, this series digs into Angular HTTP interceptors and the concrete scenarios where they prove useful.
Setting the Scene
Consider a situation where the same dataset is requested repeatedly throughout a session.
Imagine a photo library app that lists a user's albums. Navigating back and forth between views is typical, and returning to an album is a frequent occurrence.
Without any kind of caching, each revisit triggers a fresh server request that pulls every image's metadata again.
That seems like a clear opportunity for improvement.
To follow along with the code, clone the repository at this point.
The Starting Point
Here's how the app behaves initially:
The interface currently shows only the picture count per album. But checking the network panel, you'll notice that revisiting an album triggers the same request again, even if it was loaded moments earlier.
Given that the albums belong to the current user, most changes would originate from their own actions. While they are simply browsing, the data is unlikely to shift often.
Caching sounds like the right fit.
Bringing an Interceptor Into Play
To add custom behavior to the HttpClient during request processing, a new interceptor must be created to hold that logic.
An interceptor is a specialized service that defines a pipeline. Every HTTP request passes through it before hitting the server.
When it comes to caching, this means we can short-circuit a request when the response is already known.
Creating the Interceptor
The first step is to define and register the interceptor.
An interceptor implements the HttpInterceptorFn type. It receives the request and the next handler in the chain, and returns an Observable of HttpEvent:
// 📁 app/caching.interceptor.ts
export const cachingInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
// For now, this is just a pass-through
return next(req);
};
Registration involves adding it to the interceptors configuration of the HttpClient:
// 📁 main.ts
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(
// 👇 Add the interceptor to the pipeline
withInterceptors([cachingInterceptor])
),
],
}).catch((err) => console.error(err));
That's it—setup is complete.
Running the app again shows no visible change yet. That's not immediately useful, but it does confirm nothing is broken and requests still flow normally.
Building the Cache
The simplest cache structure is a Map that stores responses keyed by URL:
// 📁 app/caching.interceptor.ts
const cache = new Map<string, HttpEvent<unknown>>();
export const cachingInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
const cached = cache.get(req.url);
// 👇 If the response is known, return it without making a request
const isCacheHit = cached !== undefined;
if (isCacheHit) {
return of(cached);
}
return next(req).pipe(
// 👇 Cache the response as it flows back into our application
tap((response) => cache.set(req.url, response))
);
};
With this in place, the network tab reveals that album details are fetched only on first access:
Restricting What Gets Cached
Although caching works, the current approach stores every request. Often, we only want to cache a specific subset—here, the album-related calls.
To add this kind of filtering, the cache can be refactored into a service that encapsulates the logic:
// 📁 app/caching.service.ts
@Injectable({ providedIn: "root" })
export class CachingService {
readonly #cache = new Map<string, HttpEvent<unknown>>();
get(key: string): HttpEvent<unknown> | undefined {
return this.#cache.get(key);
}
set(key: string, value: HttpEvent<unknown>): void {
if (key.includes("album")) {
this.#cache.set(key, value);
}
}
}
This example uses a simple condition to decide whether a request qualifies for caching. In a real project, the check could be far more sophisticated.
Because interceptors execute within an injection context, dependency injection can be used directly in the interceptor definition:
// 📁 app/caching.interceptor.ts
export const cachingInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
// 👇 Rely on our dedicated service
const cache = inject(CachingService);
const cached = cache.get(req.url);
const isCacheHit = cached !== undefined;
if (isCacheHit) {
return of(cached);
}
return next(req).pipe(tap((response) => cache.set(req.url, response)));
};
From the interceptor's perspective, nothing changes. But having the cache in a dedicated service opens the door to more complex behavior down the line.
Making the Cache Expire
A useful extension is to let cached entries expire after a delay.
Adding a time-to-live to each entry achieves that:
// 📁 app/caching.service.ts
interface CacheEntry {
value: HttpEvent<unknown>;
expiresOn: number;
}
The cache can then invalidate entries when their lifetime is exceeded:
// 📁 app/caching.service.ts
const TTL = 3_000;
@Injectable({ providedIn: "root" })
export class CachingService {
readonly #cache = new Map<string, CacheEntry>();
get(key: string): HttpEvent<unknown> | undefined {
const cached = this.#cache.get(key);
if (!cached) {
return undefined;
}
// 👇 Remove the entry if expired
const hasExpired = new Date().getTime() >= cached.expiresOn;
if (hasExpired) {
this.#cache.delete(key);
return undefined;
}
return cached.value;
}
set(key: string, value: HttpEvent<unknown>): void {
if (key.includes("album")) {
this.#cache.set(key, {
value,
// 👇 Set its lifespan
expiresOn: new Date().getTime() + TTL,
});
}
}
}
Back in the network tab, we now see that requests are served from the cache for a few seconds, then fetched again once the cache expires:
Key Takeaways
We've explored how to intercept HTTP requests made by an Angular app and modify their behavior.
We also leveraged Angular's dependency injection mechanism to inject custom logic into an interceptor.
Finally, we put together a small client-side caching layer that the application can rely on.
The full resulting code is available in the associated GitHub repository:
pBouillon / DEV.ClientSideCachingWithInterceptors
Demo code for the "Client Side Caching With Interceptors" article on DEV
Leveraging Interceptors for Client-Side Caching
Companion code for the "Client Side Caching With Interceptors" article published on DEV
You can explore the complete implementation in the GitHub repository.
I trust you picked up some valuable insights along the way!
Photo by Cristina Gottardi on Unsplash


