Caching network requests on the frontend
Harsh Pathak
Posted on March 21, 2020
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 ->
- store.
- 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:
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?
Posted on March 21, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.