Nov 14 2018

Build a simple in-memory cache in Node.js

I created a Node JS project recently that required fetching some data from an external API.

A colleague suggested that I cache the API call response. I thought this suggestion was a good idea since the data did not change very often and the app ran continuously hitting the same endpoint frequently. A cache would cut down on network requests and boost performance, since fetching from memory is typically much faster than making an API request.

I ended up creating a simple in-memory cache and made it reusable, so I can repurpose it for other projects.

The app made a network request that looked something like this, using the Axios library:

const axios = require('axios');
const getUnemploymentRate = () => {
const url = 'https://api.bls.gov/publicAPI/v2/timeseries/data/LNS14000000';
return axios.get(url)
.then((result) => result.data);
}

The function retrieves the most recent U.S. unemployment figures from the U.S. Bureau of Labor Statistics. (The BLS API interface is here: https://www.bls.gov/developers/api_signature_v2.htm)

It returns a Promise that resolves to an object containing the unemployment rates for each month going back about two years.

Here's a sample use-case:

getUnemploymentRate()
.then((result) => {
// do some stuff with result
};

Output snippet:

{
"status":"REQUEST_SUCCEEDED",
"responseTime":119,
"message":[
],
"Results":{
"series":[
{
"seriesID":"LNS14000000",
"data":[
{
"year":"2018",
"period":"M10",
"periodName":"October",
"latest":"true",
"value":"3.7",
"footnotes":[
{
}
]
},
{
"year":"2018",
"period":"M09",
"periodName":"September",
"value":"3.7",
"footnotes":[
{
}
]
},
{
"year":"2018",
"period":"M08",
"periodName":"August",
"value":"3.9",
"footnotes":[
{
}
]
},
{
"year":"2018",
"period":"M07",
"periodName":"July",
"value":"3.9",
"footnotes":[
{
}
]
},
{
"year":"2018",
"period":"M06",
"periodName":"June",
"value":"4.0",
"footnotes":[
{
}
]
},
...

This request is a perfect candidate for caching since the unemployment data changes only once a month.

In developing the cache, I had a few objectives:

  • Make it a simple, in-memory storage cache
  • Make it return a JavaScript Promise regardless of serving fresh or cached data
  • Make it reusable for other types of data, not just this particular data set
  • Make the cache life, or "time-to-live" (TTL) configurable

The resulting JavaScript class has a constructor with two parameters:

fetchFunction
, the callback function used to fetch the data to store in the cache; and
minutesToLive
, a float which determines how long the data in the cache is considered "fresh". After this amount of time, the data is considered “stale” and a new fetch request will be required.

The class has four properties: the

cache
itself where the fetched data is stored;
fetchDate
, which is the date and time the data was fetched;
millisecondsToLive
, which is the
minutesToLive
value converted to milliseconds (to make time comparisons easier); and the
fetchFunction
, the callback function that will be called when the cache is empty or “stale”.

The class's three methods are:

isCacheExpired()
, which determines if the data stored in the cache was stale;
getData()
, which returns a promise that resolves to an object containing the data; and finally
resetCache()
, which provides a way to force the cache to be expired.

class DataCache {
constructor(fetchFunction, minutesToLive = 10) {
this.millisecondsToLive = minutesToLive * 60 * 1000;
this.fetchFunction = fetchFunction;
this.cache = null;
this.getData = this.getData.bind(this);
this.resetCache = this.resetCache.bind(this);
this.isCacheExpired = this.isCacheExpired.bind(this);
this.fetchDate = new Date(0);
}
isCacheExpired() {
return (this.fetchDate.getTime() + this.millisecondsToLive) < new Date().getTime();
}
getData() {
if (!this.cache || this.isCacheExpired()) {
console.log('expired - fetching new data');
return this.fetchFunction()
.then((data) => {
this.cache = data;
this.fetchDate = new Date();
return data;
});
} else {
console.log('cache hit');
return Promise.resolve(this.cache);
}
}
resetCache() {
this.fetchDate = new Date(0);
}
}

(I included console.logs so I could test to make sure the cache was working properly. You can see the results of running the cache below. I removed the log statements in the final code.)

To use the cache instead of calling the API directly every time, create a new instance of DataCache, passing in the original data fetch function as the callback function argument.

const unemploymentRateCache = new DataCache(getUnemploymentRate);

That cache instance can then be used in this way:

unemploymentRateCache.getData()
.then((result) => {
// do some stuff with result
});

To test, I created a new instance of the DataCache, but passed in a short cache life so it will expire in just a few seconds.

const unemploymentRateCache = new DataCache(getUnemploymentRate, .05);

.05 minutes will give the cache a time-to-live of about 3 seconds. Then several setTimeouts triggered the data fetching every second:

setTimeout(unemploymentRateCache.getData, 0);
setTimeout(unemploymentRateCache.getData, 1000);
setTimeout(unemploymentRateCache.getData, 2000);
setTimeout(unemploymentRateCache.getData, 3000);
setTimeout(unemploymentRateCache.getData, 4000);
setTimeout(unemploymentRateCache.getData, 5000);
setTimeout(unemploymentRateCache.getData, 6000);

The result:

$ node index.js
expired - fetching new data
cache hit
cache hit
cache hit
expired - fetching new data
cache hit
cache hit

This solution is obviously not the best one for all use cases. Since the cache is stored in memory, it doesn't persist if the app crashes or if the server is restarted. And if it's used to store a relatively large amount of data, it could have a negative impact on your app's performance.

But if you have an app that makes relatively small data requests to the same endpoint numerous times, this might work well for your purposes.

Postscript: While working on this blog post, I ran up against a rate limiter on the BLS API. You can probably avoid that by signing up for a free API registration key and passing it along with your parameters as described in the docs linked to above. Register here: https://data.bls.gov/registrationEngine/

Timothy Barmann

Share: