loading...

Caching network requests on the frontend

nosyminotaur profile image Harsh Pathak ・5 min read

Hello people!

First post on dev.to!

I'm Harsh. I'm a learning full stack developer trying to gain knowledge.

Today I'll be learning with you, how to cache network requests on the frontend.

The code discussed here is available on Github as api-cache-example.

I was writing a small app where I was fetching some timestamps from my own backend. This was a medium sized app, composed of React and Redux, written in Typescript. I was using axios as my HTTP Client.

(Just a side note, this code is in Typescript, but can be easily extended to Javascript by following similar ideas.)

I really really wanted to cache my requests on the client, so that I didn't have to make repeated calls to my API.
I thought of a simple solution, and started implementing it, using interceptors.
The idea was very simple. Have a cache, that can store objects of any type. Then invalidate them if they've been stored for a period longer than the caching period.

Simple, right?
Let's implement it, then!

First, we will be creating the cache.
We will create a file named cacheHandler.ts.
What should we have here?
Let's think logically. The cache must handle two requests ->

  1. store.
  2. retrieve if valid.

So let's make two functions, store() and isValid.

function store(key: string, value: string) {
    const finalValue = `${value}${SEPARATOR}${Date.now().toString()}`;
    localStorage.setItem(key, finalValue);
}
function isValid(key: string): IsValidResponse {
    const value = localStorage.getItem(key);
    if (value === null) {
        return {
            isValid: false,
        };
    }
    const values = value.split(SEPARATOR);
    const timestamp = Number(values[1]);
    if (Number.isNaN(timestamp)) {
        return {
            isValid: false,
        };
    }
    const date = new Date(timestamp);
    if (date.toString() === 'Invalid Date') {
        return {
            isValid: false,
        };
    }
    if ((Date.now() - date.getTime()) < CACHE_INTERVAL) {
        return {
            isValid: true,
            value: values[0],
        };
    }
    localStorage.removeItem(key);
    return {
        isValid: false,
    };
}

If you look carefully, isValid returns a response of type IsValidResponse, which is shown below:

interface IsValidResponse {
    isValid: boolean,
    value?: string,
}

We are missing the constants, so let's add that:

const SEPARATOR = '//**//';
const CACHE_INTERVAL = 0.2 * 60 * 1000;

store() is a very simple function that takes a string, adds a separator and the current date after that and stores it in localStorage. This allows isValid() to retrieve the data and the date by splitting on the separator.
Now we need to check if the date is not invalid or not expired, and we can send a boolean that tells the caller that the cache is yet not invalidated, and we can use it.
Now, what should we use as the key for storing the object in localStorage?
We'll answer that soon.

You can refer to the file directly, here.

Now, onto the axios client.
We first create a client:

export const client = axios.create({ baseURL: 'http://localhost:8080/api/widget', withCredentials: true });

baseURL can be anything, based on where you want to send a request.
I have a server at port 8080 that returns a JSON object with today's weather, but you can use any API, really.

Now we add the interceptors:

client.interceptors.request.use((request) => requestHandler(request));
client.interceptors.response.use(
    (response) => responseHandler(response),
    (error) => errorHandler(error),
);
const whiteList = ['weather'];

function isURLInWhiteList(url: string) {
    return whiteList.includes(url.split('/')[1]);
}

function responseHandler(response: AxiosResponse<any>): AxiosResponse<any> {
    if (response.config.method === 'GET' || 'get') {
        if (response.config.url && !isURLInWhiteList(response.config.url)) {
            console.log('storing in cache');
            cache.store(response.config.url, JSON.stringify(response.data));
        }
    }
    return response;
}

function errorHandler(error: any) {
    if (error.headers.cached === true) {
        console.log('got cached data in response, serving it directly');
        return Promise.resolve(error);
    }
    return Promise.reject(error);
}

function requestHandler(request: AxiosRequestConfig) {
    if (request.method === 'GET' || 'get') {
        const checkIsValidResponse = cache.isValid(request.url || '');
        if (checkIsValidResponse.isValid) {
            console.log('serving cached data');
            request.headers.cached = true;
            request.data = JSON.parse(checkIsValidResponse.value || '{}');
            return Promise.reject(request);
        }
    }
    return request;
}

Whew, a lot of code just ran past!
First, let's look at isURLInWhiteList. This is just so that we can blacklist some URLs to not be stored in the cache. This might be used with authentication routes.
Now, onto the responseHandler.
The first if is used to check if a GET request was made.

if (response.config.method === 'GET' || 'get')

If yes, then is the url not in the whitelist?

if (response.config.url && !isURLInWhiteList(response.config.url))

If these conditions are met, simply store the object in the cache with the key as the URL of the request.
Now we'll work on the requestHandler first.
The first if is used to check if a GET request was made.

if (response.config.method === 'GET' || 'get')

Then check if cache was valid

const checkIsValidResponse = cache.isValid(request.url || '');
if (checkIsValidResponse.isValid) 

If yes, this means that the cache is still valid and we can just serve that instead of sending a response!

So, add a header to the request, named cached(it could be anything, this is my personal preference), and set it to true.

request.headers.cached = true;

Set the request data here only to the cache

request.data = JSON.parse(checkIsValidResponse.value || '{}');

and then, Promise.reject the request.
Why?

This is done because then this request get sent to the errorHandler immediately. Here, we can just check if we have a cached header. If yes, this means that the data is cached, and not a real error. Else, we could just reject the error.
So that is what we do.

function errorHandler(error: any) {
    if (error.headers.cached === true) {
        console.log('got cached data in response, serving it directly');
        return Promise.resolve(error);
    }
    return Promise.reject(error);
}

If the cached header is present, we return a Promise.resolve so that axios treats it like no error had ever occurred and we get this data inside the .then instead of the .catch. So the get caller never knew that caching was happening behind the scenes!

And if it is any other error, just return a Promise.reject instead, so that it behaves like a normal error! Now isn't that smart?

I used this design in a React app that looked like the following:

Cache improvements

1604 ms to a whopping 3ms.
That is 535 times faster than the non-cached version.
And by changing the constant CACHE_INTERVAL, we can modify how long the cache should stay validated.

You can checkout the project at my GitHub account

One last question before leaving. How do I use this with fetch?
Well, some questions should be left to the reader to answer themselves. Otherwise, what's the purpose of learning?

Discussion

pic
Editor guide
Collapse
samcs100 profile image
samcs100

This is a great work. Is there any work around for post calls means if i want to serialize the request and send? Can you suggest me how it will be done or any suggestion? Right now, i want to cache the request and set the response in localstorage. So, if i call the same service with same params then i will get that cached response.

Collapse
yellow1912 profile image
yellow1912

Lets say that for some reason you have multiple requests at the same time asking for the same thing, then the cache is not ready yet and these requests still get sent anyway?

Collapse
nosyminotaur profile image
Harsh Pathak Author

One of the reasons I used local storage here was simplicity. If I used IndexedDB I'd have better results for multiple requests because they'd all be async.
Also, I'm not saying this is perfect. This is just a start and we have a long way to go to implement perfect caching.

Collapse
yellow1912 profile image
yellow1912

I got around this by issuing another promise. Something hacky like this:

  1. If cache key does not exist, mark that cache key as loading
  2. Start loading, once done then unmark the loading key. Resolve all promises (see below)
  3. If any request (same URL etc) sent within the loading phase, return a promise.
Collapse
mbadola profile image
Mayank Badola

Great write up Harsh 🙌🏽

Did you also look into using adapters , to achieve your goal?

Collapse
nosyminotaur profile image
Harsh Pathak Author

I used them, but the end result got too complicated for this article, because I couldn't find a direct way to continue or block a request in the same request.
So I decided to stick with this one.

Collapse
samcs100 profile image
samcs100

As of now using axios-cache-adaptor but it's limited to get calls... Your idea is much simpler than that.