DEV Community

Cover image for Client Side Caching With Interceptors
Pierre Bouillon for This is Angular

Posted on

Client Side Caching With Interceptors

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:

Initial State

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);
};
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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))
  );
};
Enter fullscreen mode Exit fullscreen mode

If we run our app again, we can see that the album details are requested only when accessed for the first time:

Simple Caching

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)));
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

With TTL

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:

GitHub logo pBouillon / DEV.ClientSideCachingWithInterceptors

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)

Collapse
 
yutamago profile image
Yutamago

How is this different from setting up a Service Worker with caching?

Collapse
 
speedopasanen profile image
Toni

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.