Angular is known for the vast amount of built-in feature it has, but sometimes we only use a few of them without knowing what their full potential could be.
Angular is renowned for its extensive array of built-in features. However, at times, users only scratch the surface, unaware of the full potential that lies within.
Taking inspiration from @armandotrue and his excellent series Superpowers with Directives and Dependency Injection, in this series, we are going to explore what HTTP interceptors are in Angular, and what practical use cases can be solved with them.
Today's Problem
Today let's imagine we have a piece of data that is fetched over and over.
For example, we could build a photos library app that is listing the user's albums. It's common for users to navigate back and forth within the application, revisiting their photos.
Without caching, this implies that every time a user accesses an album, all the details of each picture must be retrieved from the server anew.
This looks like something we could improve!
If you would like to follow along, you can clone the repository at this step
Initial State
Here is our app in action:
For now it just displays the amount of photos in each album. But if we look at the network tab, we can see that whenever we access an album, the app fetches its details again, even if we just clicked into it a moment ago.
Since these albums belong to the user, chances are any changes will come from their side. As long as they're just browsing, it might not change a lot.
Sounds like caching could help!
Bringing Interceptor to the Rescue
To add additional behavior to the HttpClient
while it handles requests, we need to create a new interceptor to define the associated logic.
An interceptor is a special type of service that let us configure a pipeline an HTTP request will pass through before an actual request is made to the server.
For caching, it means we could intercept the request if we already know what the response will be.
Creating the Interceptor
To define an interceptor, we first need to create and register it.
An interceptor is of type HttpInterceptorFn
, which takes the request and the next handler in the pipeline as parameters, and returns the result as 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);
};
To register it, we need to add it to the interceptors 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));
We're all set!
If we run our appliation again, nothing has changed for now. While this might not be really useful, at least it indicates that we didn't break anything and HTTP requests still flows into our app.
Creating our Cache
The most straightforward approach for our cache is to have it as a Map
of responses by urls:
// 📁 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))
);
};
If we run our app again, we can see that the album details are requested only when accessed for the first time:
Restricting the Cache
While it does cache our requests, the downside of this approach is that all requests will be cached while we might want to cache only the ones related to the album.
Since we would like to add some logic to our cache, we can transform it to a smarter piece of logic and upgrade it to a service:
// 📁 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);
}
}
}
Here, we are only using a simple condition to check if this should be cached. In a real world application you might prefer relying on a smarter way of checking this.
Since interceptors function are called within an injection context, we can use dependency injection into its 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 point of view, nothing has changed. However, since we now have a dedicated service, adding more complex logic will be greatly simplified.
Enhancing the Cache
Finally, we could want the cache to expire after some time.
To achieve this, we can enhance it a little bit more to include a time to live to each entry:
// 📁 app/caching.service.ts
interface CacheEntry {
value: HttpEvent<unknown>;
expiresOn: number;
}
And expire the associated value whenever needed:
// 📁 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,
});
}
}
}
If we look at the network tab of our browser again, we can now see that an each request is cached for a few seconds, and the cache expires afterwards:
Takeaways
In this article we saw how to intercept HTTP request made by our Angular application and how to manipulate them.
We also saw how to take advantage of Angular's dependency injection system to add custom logic to an interceptor.
Finally, we also built a small client-side caching system for our application to use.
If you would like to check the resulting code, you can head on to the associated GitHub repository:
pBouillon / DEV.ClientSideCachingWithInterceptors
Demo code for the "Client Side Caching With Interceptors" article on DEV
Leveraging Angular Interceptors
Client Side Caching With Interceptors
Demo code for the "Client Side Caching With Interceptors" article on DEV
I hope that you learn something useful there!
Photo by Cristina Gottardi on Unsplash
Top comments (2)
How is this different from setting up a Service Worker with caching?
Yeah, or how about proper cache headers in your API layer? 🤣 Caching really shouldn't be a responsibility of a JS app but handled on lower layers. But sometimes you just need that extra control I guess.