Side note: Get alerted when your Node.js app goes down
Head over to Better Stack and start monitoring your endpoints in 2 minutes.
Caching is one of the most effective optimizations that you can apply to an application. It involves storing some data in a temporary location called a cache so that it can be retrieved much faster when it is requested in the future. This data is often the result of some earlier computation, API request, or database query.
The main goal of caching is to improve application performance. Since data can often be retrieved much faster from the cache, the need to repeat a network request or database query for the same data is reduced and this can significantly reduce the latency associated with a particular operation. Caching also reduces network costs since less data is transferred, and makes application performance much more reliable and predictable as you're less susceptible to the effects of network congestion, service downtime, load spikes, and other challenges.
In this article, we'll discuss how to set up caching in a Node.js application through Redis, a popular and versatile in-memory database that is often used as a distributed database cache for web applications. It can be used with a wide variety of programming languages and environments, and it has a lot of other uses besides caching.
Head over to Better Stack and start monitoring your endpoints in 2 minutes.
Before you proceed with the remainder of this tutorial, ensure that you have met the following requirements:
In this tutorial, we will demonstrate the concept of caching in Node.js by modifying the Hacker News Search application from the earlier tutorial on Express and Pug. You don't need to follow the tutorial to build the application; you can clone it to your machine using the command below:
git clone https://github.com/betterstack-community/hacker-news
Once the command exits, change into the newly created hacker-news
directory
and install the project dependencies through npm
:
cd hacker-news
npm install
Afterward, examine the contents of the server.js
file in your text editor.
nano server.js
The relevant lines in the file are shown below:
. . .
async function searchHN(query) {
const response = await axios.get(
`https://hn.algolia.com/api/v1/search?query=${query}&tags=story&hitsPerPage=90`
);
return response.data;
}
app.get('/search', async (req, res, next) => {
try {
const searchQuery = req.query.q;
if (!searchQuery) {
res.redirect(302, '/');
return;
}
const results = await searchHN(searchQuery);
res.render('search', {
title: `Search results for: ${searchQuery}`,
searchResults: results,
searchQuery,
});
} catch (err) {
next(err);
}
});
. . .
The /search
route above expects a query which is subsequently passed on to the
searchHN()
function for querying the
Hacker News API provided by Algolia to get the
top stories for that search term. Once the JSON response from the API is
retrieved, it is used to render an HTML document through the search.pug
template in the views
folder.
You can test the application by starting the development server through the command below. Note that the server will automatically restart whenever a change is detected in any project files.
npm run dev
Afterward, head over to http://localhost:3000
(or
http://<your_server_ip>:3000
) in your web browser and make a search request
for a popular term. You should observe that relevant results from Hacker News
are fetched and displayed for your query.
Now that our demo application has been set up, let's create and run a benchmark
to determine how quickly our application can resolve requests to the /search
route so that we can figure out our current performance before we implement
caching in an attempt to improve it.
In this step, we will utilize the Artillery package to measure our application's performance in its current state so that we can easily quantify the differences after adding caching through Redis. Having a baseline measurement is an essential step before carrying out any performance optimization. It helps you determine if the optimization had the desired effect and if the trade-offs are worth it.
Ensure that you are in the project directory, then install the artillery
package globally through npm
:
npm install -g artillery
Afterward, the artillery
command should be accessible. Ensure that running
artillery --version
yields a version number of 2.0.0 or higher:
artillery --version
Telemetry is on. Learn more: https://artillery.io/docs/resources/core/telemetry.html
___ __ _ ____
_____/ | _____/ /_(_) / /__ _______ __ ___
/____/ /| | / ___/ __/ / / / _ \/ ___/ / / /____/
/____/ ___ |/ / / /_/ / / / __/ / / /_/ /____/
/_/ |_/_/ \__/_/_/_/\___/_/ \__ /
/____/
VERSION INFO:
Artillery Core: 2.0.0-12
Artillery Pro: not installed (https://artillery.io/product)
Node.js: v16.14.0
OS: linux
Artillery utilizes
test definition files
to determine the configuration parameters for a test run. They are YAML files
that consist of two main sections: config
and scenarios
. The former
specifies settings for the test, such as target URL, HTTP headers, virtual
users, requests per user, and more, while the latter describes the actions that
each virtual user must take during the test.
Create an artillery.yml
file in the root of your project directory and open it
in your text editor:
nano artillery.yml
Populate the file with the following code:
config:
target: "http://localhost:3000"
phases:
- duration: 60
arrivalRate: 10
scenarios:
- name: "Search HN for Programming content"
flow:
- get:
url: "/search?q=Programming"
In the config
section, the target
is the URL of the server. This phases
block describes a load phase that lasts 30 seconds, where 10 new virtual users
are created every second. When a virtual user is created, they execute the
scenario that is defined in the scenarios
block which is making a GET request
to the /search
route. After all the virtual users have completed their
scenarios, the test will complete and a summary of the results will be printed
to the console.
Although this test probably isn't representative of a real-world scenario, it
will give us valuable data about the performance of the /search
endpoint that
we can refer back to after carrying out the planned optimizations. You should
check out the
Artillery docs to
discover how to create realistic user flows for your application.
Let's go ahead and execute the artillery.yml
script through the command shown
below. Ensure that the Hacker News application is running in a separate terminal
before executing this command.
artillery run artillery.yml
You should observe the following summary at the bottom of the output produced by Artillery.
. . .
All VUs finished. Total time: 1 minute, 1 second
--------------------------------
Summary report @ 12:10:08(+0000)
--------------------------------
http.codes.200: ................................................................ 600
http.request_rate: ............................................................. 10/sec
http.requests: ................................................................. 600
http.response_time:
min: ......................................................................... 187
max: ......................................................................... 2534
median: ...................................................................... 584.2
p95: ......................................................................... 1002.4
p99: ......................................................................... 1587.9
http.responses: ................................................................ 600
vusers.completed: .............................................................. 600
vusers.created: ................................................................ 600
vusers.created_by_name.Search HN for Programming content: ...................... 600
vusers.failed: ................................................................. 0
vusers.session_length:
min: ......................................................................... 191.5
max: ......................................................................... 2539.2
median: ...................................................................... 596
p95: ......................................................................... 1002.4
p99: ......................................................................... 1587.9
The exact numbers will likely differ in your test run, but the following is the explanation of the report above:
Now that we have some quantifiable data on the current performance of the
/search
route in our Node.js application, let's go ahead and install Redis in
the next section.
This section will describe how to install and set up Redis on Ubuntu 20.04. If you're on a different operating system, head to the download page to get the latest version for your system. Note that Redis is not officially supported in Windows, but you're able to install and set it up through Windows Subsystem for Linux (WSL) in Windows 10 or later.
Although Redis is already available in the default Ubuntu repositories, installing it from there is somewhat discouraged as the available version is usually not the latest. To ensure that we get the latest stable version, we'll use the official Ubuntu PPA which is maintained by the Redis team.
Run the following command in a new terminal instance to add the repository to
the apt
index. The local package index should update immediately after adding
the repository.
sudo add-apt-repository ppa:redislabs/redis -y
Afterward, install the redis
package through the command below:
sudo apt install redis
Once the command finishes, verify the version of Redis that was installed:
redis-server --version
Redis server v=6.2.6 sha=00000000:0 malloc=jemalloc-5.1.0 bits=64 build=9c9e426e2f96cc51
After installing Redis, open its configuration file in your text editor:
sudo nano /etc/redis/redis.conf
Inside the file, find the supervised
directive and change its value from no
to systemd
. This directive declares the init system that manages Redis as a
service. It's being set to systemd
here as that's what Ubuntu uses by default.
. . .
# If you run Redis from upstart or systemd, Redis can interact with your
# supervision tree. Options:
# supervised no - no supervision interaction
# supervised upstart - signal upstart by putting Redis into SIGSTOP mode
# supervised systemd - signal systemd by writing READY=1 to $NOTIFY_SOCKET
# supervised auto - detect upstart or systemd method based on
# UPSTART_JOB or NOTIFY_SOCKET environment variables
# Note: these supervision methods only signal "process is ready."
# They do not enable continuous liveness pings back to your supervisor.
supervised systemd
. . .
Save and close the file after making the changes, then start the Redis service using the command below:
sudo systemctl start redis
Go ahead and confirm that the Redis service is running through the following command:
sudo systemctl status redis
You should observe the following output:
● redis-server.service - Advanced key-value store
Loaded: loaded (/lib/systemd/system/redis-server.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2022-01-07 10:47:24 UTC; 4s ago
Docs: http://redis.io/documentation,
man:redis-server(1)
Main PID: 793992 (redis-server)
Status: "Ready to accept connections"
Tasks: 5 (limit: 1136)
Memory: 3.0M
CGroup: /system.slice/redis-server.service
└─793992 /usr/bin/redis-server 127.0.0.1:6379
Jan 07 10:47:24 ubuntu-20-04 systemd[1]: Starting Advanced key-value store...
Jan 07 10:47:24 ubuntu-20-04 systemd[1]: Started Advanced key-value store.
This indicates that Redis is up and running, and it is set up to automatically start every time the server is rebooted.
As a final way to confirm that your Redis installation is functioning correctly,
launch the redis-cli
prompt:
redis-cli
In the resulting prompt, enter the ping
command. You should receive a PONG
output:
127.0.0.1:6379> ping
PONG
You can type exit
afterwards to exit the redis-cli
prompt.
Now that your Redis instance is fully operational, let's go ahead and install the necessary packages for working with Redis in Node.js in the next section.
Utilizing Redis as a caching solution for Node.js applications is made easy
through the redis package provided by the
core team. Go ahead and install it in your application through the npm
as
shown below:
npm install redis
Once the installation is completed, open the server.js
file in your text
editor:
nano server.js
Import the redis
package at the top of the file below the other imports, and
create a new Redis client as shown below:
const express = require('express');
const path = require('path');
const axios = require('axios');
const redis = require('redis');
const redisClient = redis.createClient(6379);
(async () => {
redisClient.on('error', (err) => {
console.log('Redis Client Error', err);
});
redisClient.on('ready', () => console.log('Redis is ready'));
await redisClient.connect();
await redisClient.ping();
})();
. . .
The default port for Redis is 6379
so that's what its supplied to the
createClient()
method. Other configuration options for this method can be
accessed through its
documentation page.
After creating a Redis client, you should listen for at least the ready
and
error
events before proceeding.
Once you save the file, the application will restart, and you will see 'Redis is ready' in the output provided that your Redis instance is up and running.
. . .
[rundev] App server restarted
server-0 | Hacker news server started on port: 3000
server-0 | Redis is ready
In the next section, we'll implement a caching strategy for the /search
route
in our Hacker News application so that the speed of resolving search queries is
greatly improved.
In this step, you'll cache the responses for each search term in Redis so that they can be reused for subsequent requests if the exact search term is repeated. We'll utilize the popular Cache-Aside Pattern which specifies that an attempt is made to retrieve the requested data from the cache first before reaching out to the original data source if the item does not exist in the cache. Subsequently, the retrieved data is stored in the cache so that repeated requests for the same data can be resolved more quickly.
When a request is fulfilled by successfully retrieving the requested data from the cache, it is known as a cache hit. If the original data store has to be accessed to fulfill a request, it is known as a cache miss. A good caching strategy will ensure that most requests will result in a cache hit. However, the occasional cache miss cannot be avoided, especially for data that is updated frequently.
Start by opening the server.js
file in your text editor:
nano server.js
Go ahead and update the /search
route as follows:
app.get('/search', async (req, res, next) => {
try {
const searchQuery = req.query.q;
if (!searchQuery) {
res.redirect(302, '/');
return;
}
let results = null;
const key = 'search:' + searchQuery.toLowerCase();
const value = await redisClient.get(key);
if (value) {
results = JSON.parse(value);
console.log('Cache hit for', key);
} else {
console.log('Cache miss for', key);
results = await searchHN(searchQuery);
redisClient.setEx(key, 300, JSON.stringify(results));
}
res.render('search', {
title: `Search results for: ${searchQuery}`,
searchResults: results,
searchQuery,
});
} catch (err) {
next(err);
}
});
This code uses the Redis client that we created in the previous step to cache
and retrieve the JSON response received from the Algolia API. The Redis key
consists of a concatenation of the search:
prefix and a lowercase version of
the search term so that each key is unique to the specified search term. The
first step is to use the get()
method to check the cache for the specified
key
. If this key doesn't exist, this method will return null
so that you'll
know to query the source for the data.
After retrieving the data, you can then use set()
or setEx()
to store the
data in the cache under the key name. The setEx()
method is preferred here so
that a timeout of five minutes (300 seconds) is set on the key. Therefore, each
cached result is reused for a maximum of five minutes before it is expired and
refreshed which helps us avoid serving stale results for a specific search term.
Now that you've integrated the Redis library to implement a basic caching strategy, let's go ahead and rerun the earlier benchmark to see if the changes have had the desired effect.
Return to the terminal and execute the command below to send virtual users to your server once again:
artillery run artillery.yml
You should observe the following results once the command exits:
. . .
All VUs finished. Total time: 1 minute, 1 second
--------------------------------
Summary report @ 21:49:49(+0000)
--------------------------------
http.codes.200: ................................................................ 600
http.request_rate: ............................................................. 10/sec
http.requests: ................................................................. 600
http.response_time:
min: ......................................................................... 10
max: ......................................................................... 303
median: ...................................................................... 16.9
p95: ......................................................................... 37
p99: ......................................................................... 54.1
http.responses: ................................................................ 600
vusers.completed: .............................................................. 600
vusers.created: ................................................................ 600
vusers.created_by_name.Search HN for Programming content: ...................... 600
vusers.failed: ................................................................. 0
vusers.session_length:
min: ......................................................................... 20.8
max: ......................................................................... 348.1
median: ...................................................................... 26.3
p95: ......................................................................... 48.9
p99: ......................................................................... 70.1
Compared to the previous run, we get much lower min and median response times (24ms and 26.3ms respectively), and 95% of all the requests were completed within 48.9ms. This is a massive improvement from earlier numbers (187ms, 584ms, and 1002ms respectively). The response times are much lower in this run because only the first couple of users hit the API directly, while the vast majority of the requests were fulfilled using the cached data.
Aside from the primary benefit of reduced latency and faster response times for users, it also minimizes our costs especially when we're working with a paid API since we can reuse a response several times before it needs to be refreshed. APIs also often have rate limits or downtime so this approach also helps prevent resource starvation.
Now that we've seen an example of how effective caching can be at improving the speed of request completion, let's discuss a few considerations for deciding what to cache and how we can achieve a high cache hit rate in our applications.
When deciding on a caching strategy for your Node.js application, you need to find the most optimal way to cache data to achieve a high cache hit rate. The ideal candidates for caching are those data that can be reused for several requests before it needs to be updated. If the data changes frequently such that it cannot be reused for a subsequent request, then it is not a good candidate for caching.
In the above example, the results for a search term for Hacker News is unlikely to change significantly within five minutes so it makes sense to keep reusing the response from the Hacker News API for that duration of time. Depending on the sensitivity of the data, you can potentially cache it for more extended periods or even forever if the data is never going to change and thus can be reused indefinitely.
Another consideration for caching data is how frequently it is requested. Data that is not requested often should probably not be cached even if it can be reused. This is because cache storage is usually limited so you want it only to be used for frequently accessed resources in your application's hotspots.
The cache-aside pattern discussed and implemented above is just one of many patterns that you can employ for caching. Here's a brief overview of some other patterns that you can investigate further:
Before we wrap up this tutorial, let's discuss another important aspect of caching that you should be aware of.
There are only two hard things in Computer Science: cache invalidation and naming things. -- Phil Karlton
Cache invalidation and cache eviction are important considerations when implementing a caching strategy in your application. The former deals with how cache entries are refreshed or removed when they go stale, while the latter is solely about removing items from the cache regardless of whether they are stale or not.
In the example used for this tutorial, we're using a Time To Live (TTL) value to invalidate our cached objects after five minutes. When an application attempts to read an expired key, it is treated as though the key is not found and the original data store is queried once again. This approach guarantees that even if the cached value goes stale, it won't be stale for more than five minutes. Depending on the data being cached, the tolerance for staleness can be lower or higher. For example, a trending news stories site might tolerate only a few seconds of staleness, but Covid-19 statistics may only need to be updated once or twice per day.
Cache eviction refers to a policy by which older items are removed from the cache as newer ones are added. Since cache storage is usually limited compared to the primary data store, having such a policy will ensure that only relevant items are present in the cache at all times. We won't be able to cover the different eviction policies in this article, but you should investigate the following: Least Recently Used (LRU), Least Frequently Used (LFU), Most Recently Used (MRU), First In First Out (FIFO).
In this article, we discussed the what and why of caching in Node.js, then demonstrated how to benchmark an endpoint to measure its performance before applying any optimizations. Subsequently, we set up Redis and integrated it with our Node.js application before implementing the cache-aside pattern for caching API responses. We then repeated the benchmark to illustrate how caching can measurably improve application performance before rounding off with a discussion on some important caching concepts to be aware of.
The entire code used in this tutorial can be downloaded from GitHub. Thanks for reading, and happy coding!
Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.
Write for usWrite a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.
community@betterstack.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github