How to Cache Node.js Applications with Redis
Caching is the process of storing copies of files in a cache — a temporary storage that is much faster to access than other available storage methods in the system.
When developing Node.js applications, caching becomes highly relevant because database queries can take significantly longer than fetching data from temporary storage.
For example, there is no need to reload the HTML markup of a webpage for every user request to the server — this would add several (sometimes dozens of) milliseconds to the response time. Storing the page (or JSON data for rendering in a SPA application) is much more efficient in the cache.
In simple terms, caching is about optimization.
This article will explore how to cache application data in a Node.js application using Redis with the Express framework.
What is Redis?
Redis (Remote Dictionary Server) is an open-source, in-memory database with simple "key-value" data structures.
The terminology may vary. Some refer to Redis as a database, others as a caching tool, or something else. The key point is that Redis stores data in RAM instead of a hard drive, which results in higher performance. This is why Redis is referred to as an "in-memory" database.
Although the data is kept in RAM, it is periodically saved to a hard drive in the form of snapshots.
Redis is often used together with relational DBMSs, such as managed PostgreSQL.
Installing Redis Server
The installation process for Redis differs depending on the operating system, and you can find detailed instructions for each system on the official website.
This article focuses on Ubuntu or Debian. Therefore, we will install the latest version of Redis from the official APT (Advanced Packaging Tool) repository — packages.redis.io:
sudo apt update
sudo apt install redis
Once this is done, the Redis server is ready to use.
For Windows, you need to download the installer from the official GitHub repository. After installation, start the Redis server with the following CLI command:
redis-cli
For macOS, you can install Redis using the Homebrew package manager:
brew install redis
Once installed, start the server with:
redis-server
Node.js Project Configuration
Before we dive into how to interact with Redis through a Node.js application, let's first create a separate working directory and navigate to it:
mkdir node_redis
cd node_redis
As usual, let's create a package.json configuration file with a minimal set of data:
{
"name": "node_redis",
"version": "1.0.0",
"description": "Simple example of using Redis by Hostman",
"main": "index.js",
"license": "MIT",
"dependencies": {
"express": "latest",
"axios": "latest",
"redis": "latest"
}
}
Note the specified dependencies. For this project, we will need the latest versions of the Express framework and the official Redis client for Node.js from NPM. This is a separate library that provides a high-level API (classes and functions) for interacting with a Redis server.
The Axios module will help parse the JSON data the remote server will return in response to API requests.
To install these dependencies, we will use the NPM package manager. If you don't have it yet, install it with the following command:
sudo apt install npm
You can read a separate guide on how to install the latest version of Node.js on Ubuntu. Since the app will use the async/await syntax, the minimum required version of Node.js is 8.
Now, once all dependencies are specified, they can be installed:
npm install
Express Application Without Caching
In this example, the application will use a fake API from JSONPlaceholder, specifically created for such purposes. We will send a request to the URL https://jsonplaceholder.typicode.com/posts/1 and receive mock data in JSON format:
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}
Subsequent loading of data from the cache (instead of making repeated requests to the remote server) will increase the speed of the application. However, we will first implement the process of handling user requests without caching and add it later.
Let's first create and edit our index.js file. The script will use modern JavaScript (ES6) syntax with async/await operators whenever possible:
const express = require("express"); // import the Express framework
const axios = require("axios"); // import the Axios module for working with JSON data
const app = express(); // create an instance of the app
// create an async function to request data from the remote server using axios
async function getRemoteData() {
const information = await axios.get(`https://jsonplaceholder.typicode.com/posts/1`); // send a request to the remote API
console.log("There was a request to a remote server"); // log the informational message to the console
return information.data; // return the raw JSON data
}
// create an async function to handle user requests
async function onRequest(req, res) {
let results = await getRemoteData(); // call the previously created function to get data from the remote server
if(results.length === 0) throw "API error"; // handle empty responses with an error
res.send(results); // respond to the user's request with the raw JSON data
}
app.get('/', onRequest); // attach the previously created function to the GET request hook
app.listen(8080); // start listening for incoming requests on the default HTTP server port
Now, you can run the script, open localhost in your browser, and see the raw JSON data displayed on the web page:
node index.js
Each request to the local server will, in turn, trigger a request to the remote server. For example, if you refresh the page three times in the browser, the message "There was a request to a remote server" will be printed three times in the terminal of the running Node.js server.
But why? From a rational perspective, this is unnecessary. The data retrieved the first time should be cached to reduce the number of operations and user wait times. This is relevant only when the data is expected to remain static for a certain period — in other words, you can only cache data that doesn't change frequently.
Express Application with Caching
Let's modify the previous example so our application "learns" to cache data.
To do this, we'll first connect the Redis client — add a new line at the beginning of the index.js:
const redis = require("redis");
Now, naturally, we need to connect to the Redis server we started earlier, and only after that can we set and get keys. Let's add a few more lines of code:
(async () => {
client = redis.createClient();
client.on("error", (error) => console.log('Something went wrong', error)); // set up an error handler for Redis connection issues
await client.connect(); // connect to the Redis server
})();
Note that the connection to the Redis server is done in an anonymous self-invoking asynchronous function. This ensures that all pre-configuration steps are executed sequentially. Additionally, the connect function returns a promise, which can be handled using then/catch or inside an async function.
In our example, the caching logic will be as follows: if the API request to the remote server is made for the first time, we cache the obtained data. If the data has been previously retrieved, it will be available in the cache — we fetch it and send it to the user.
Let's modify the onRequest function (middleware) to implement caching:
async function onRequest(req, res) {
let results; // declare the variable for the result
const cacheData = await client.get("post"); // try to get the "post" key from Redis database
if (cacheData) {
results = JSON.parse(cacheData); // parse the data from a raw string format into a structure
} else {
results = await getRemoteData(); // call the function to get data from the remote server
if (results.length === 0) throw "API error"; // handle empty result with an error
await client.set("post", JSON.stringify(results)); // cache the obtained data
}
res.send(results); // respond to the request with JSON data
}
Notice that the get function returns null if no value is saved for the given key in Redis. If this happens, an API request is made to the remote server. If data exists in the cache, it is retrieved and sent to the user.
The set function is responsible for caching — it stores the given value under a specified key so we can retrieve it later with get.
The full code of the application at this stage looks like this:
const express = require("express"); // import Express framework
const axios = require("axios"); // import Axios module for working with JSON data
const redis = require("redis"); // import Redis client
const app = express(); // create an instance of the application
// Connect to Redis server
(async () => {
client = redis.createClient();
client.on("error", (error) => console.log('Something went wrong', error)); // set up an error handler for Redis connection issues
await client.connect(); // connect to the Redis server
})();
// create an asynchronous function to request data from the remote server using axios
async function getRemoteData() {
const information = await axios.get(`https://jsonplaceholder.typicode.com/posts/1`); // send a request to the remote server with the API
console.log("There was a request to a remote server"); // log an informational message to the console
return information.data; // return the obtained JSON data in raw form
}
// create an asynchronous function to handle user requests
async function onRequest(req, res) {
let results; // declare the variable for the result
const cacheData = await client.get("post"); // attempt to retrieve the "post" variable from the Redis database
if (cacheData) {
results = JSON.parse(cacheData); // parse the data from a raw string into a structured format
} else {
results = await getRemoteData(); // call the function to fetch data from the remote server
if (results.length === 0) throw "API error"; // handle empty result with an error
await client.set("post", JSON.stringify(results)); // cache the obtained data
}
res.send(results); // respond with the JSON data
}
// run the HTTP server with the necessary configurations
app.get('/', onRequest); // associate the created function with the GET request hook
app.listen(8080); // start handling incoming requests on the standard HTTP server port
Setting Cache Expiration
We should periodically update the data stored in the cache to prevent it from becoming outdated.
In real-world projects, APIs often provide additional information about how frequently cached data should be updated. This information is used to set a timeout — the duration for which the data in the cache remains valid. Once this time expires, the application makes a new request to obtain fresh data.
In our case, we will take a simpler approach that is commonly used in practice. We will set a constant cache expiration time of 60 seconds. After this period, the application will make another request to the remote server for fresh data.
It’s important to note that cache expiration is handled by Redis. This can be achieved by providing additional parameters when using the set function.
To implement this, we will modify the set function call to include an additional structure as the third argument. Thus, the line:
await client.set("post", JSON.stringify(results)); // cache the received data
Will be updated to:
await client.set("post", JSON.stringify(results), { EX: 60, NX: true }); // cache the received data with expiration
In this case, we updated the previous line of code by adding the EX parameter, which is the cache expiration time in seconds. The NX parameter ensures that the key is only set if it does not already exist in the Redis database. This last parameter is important because re-setting the key would update the cache timeout without it, preventing it from fully expiring.
Now, the Redis database will store the value of the post key for 60 seconds and then delete it. This means that every minute, the cacheData variable in our app will receive a null value, triggering an API request to the remote server and re-caching the obtained result.
Conclusion
This article demonstrated how in-memory storage can serve as a "mediator" between processing and storing data on solid-state drives.
All of this is a form of caching that reduces unnecessary computational (and network) operations, thereby improving the application's performance and reducing the server's load.
As shown, you can quickly set up such storage using Redis with a Node.js client. In our case, we used a mock API that returned trivial JSON data. In one scenario, the data was requested every time, while in the other, it was cached — sometimes with an expiration time.
The examples provided are just the basics. As always, you can find more information on using Redis in the official documentation. The same applies to the documentation for Express and Axios.
26 December 2024 · 11 min to read