Build a Product API with Redis
Add caching, rate limiting, and sessions to a Node.js API using Redis.
Introduction
30 Second Summary
Think about the last time you refreshed a page and it loaded instantly. Behind the scenes, that speed usually comes from a layer of temporary storage sitting between you and the actual database, serving data that was already fetched moments ago.
In this project, you will start with a deliberately slow API and progressively add Redis to solve three real problems: sluggish responses, unprotected endpoints, and missing authentication.
What You'll Build
You hit the same product endpoint twice and watch your terminal reveal the speed difference in real time: 200ms on the first request, under 5ms on the second.
By the end of this project, you'll have:
- A caching layer that turns 200ms database responses into sub-5ms responses using the cache-aside pattern with automatic expiration.
- A rate limiter that detects and blocks clients sending too many requests, built with Redis atomic counters.
- Session-based authentication where users must log in before accessing products, with sessions stored entirely in Redis so they survive server restarts.
- Secret Mission: Add cache invalidation so updating a product automatically clears stale cached data.
Are there any prerequisites?
This project assumes basic familiarity with Node.js and Express (writing routes and handling requests). You'll also need Docker Desktop installed to run Redis locally.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
You're going to feel three real problems that a traditional database can't solve well on its own. Then you'll fix each one with Redis. Committing to that goal now gives every step a clear purpose.
Launch Redis and Scaffold the Project
Before you can build a Redis-powered API, you need two things running on your machine. A Redis server to store your cached data, and a Node.js project to talk to it.
Redis doesn't natively support Windows, so you'll use Docker to spin it up in a container. Then you'll scaffold a Node.js project and verify that your app can communicate with Redis.
In this step, get ready to:
- Start a Redis container with Docker.
- Create a Node.js project and install dependencies.
- Connect to Redis from your application code.
Start Redis using Docker
Docker lets you run Redis in an isolated container without installing it directly on your operating system. You'll pull the official Redis image and map its default port to your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Docker Desktop and press Enter to open it.
- Wait until the Docker engine status shows a green indicator (bottom left corner) before continuing.
Why Docker for Redis?
Redis doesn't ship a native Windows installer. Docker solves this by running Redis inside a Linux container on any operating system. One command gives you a fully working Redis server.
- Open a terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal (macOS) or PowerShell (Windows), and pressing Enter.
- Pull and start a Redis container by running this command:
docker run -d --name redis -p 6379:6379 redis
What does this command do?
- docker run downloads the Redis image (if not already cached) and starts a new container.
- -d runs the container in the background so your terminal stays free.
- --name redis gives the container the name redis so you can reference it easily later.
- -p 6379:6379 maps port 6379 on your machine to port 6379 inside the container.
- Confirm the container is running by running:
docker ps
You should see a row with the image redis and the name redis in the output, with status showing "Up".
Before you continue, what do you think will happen if you send a PING command to this Redis server?
- Verify Redis responds to commands by running:
docker exec -it redis redis-cli PING
You should see PONG printed back. This confirms Redis is running and ready to accept connections on port 6379.
Not seeing PONG?
- Make sure Docker Desktop is fully started (green status indicator). If the engine is still loading, the container won't start.
- If you see a "name already in use" error, a previous container with that name exists. Remove it with docker rm -f redis and re-run the docker run command.
- help me troubleshoot why my Redis Docker container isn't responding to PING
Scaffold the Node.js project
Now you'll create a project folder with all the packages your API needs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir redis-product-api
cd redis-product-api
- Initialize a new Node.js project by running:
npm init -y
You should see a JSON summary of your new package.json printed in the terminal.
- Install the four dependencies your project needs by running:
npm install redis express connect-redis express-session
What are these packages?
- redis is the official Node.js client for communicating with a Redis server.
- express is the web framework for building your API routes.
- connect-redis is a session store adapter that saves session data in Redis.
- express-session adds session support to Express so you can track logged-in users.
- Confirm your project files exist by running:
ls
You should see node_modules, package.json, and package-lock.json in the output.
Seeing 'npm: command not found'?
- This means Node.js isn't in your system path. Close and reopen your terminal, then try again.
- If it still fails, confirm Node.js is installed by checking your installed programs list.
- help me fix npm command not found when trying to install packages
Connect to Redis from Node.js
Your Redis server is running and your project has its packages. Now you'll write the code that connects your Node.js app to Redis.
- Open your project in VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
- Click File in the top menu bar.
- Click Open Folder.
- Select the redis-product-api folder on your Desktop, then click Open.
- Create a new file called redisClient.js by right-clicking in the Explorer sidebar and selecting New File.
- Add the following code to redisClient.js:
const { createClient } = require("redis");
const client = createClient({
url: "redis://localhost:6379",
});
client.on("error", (err) => console.log("Redis Client Error", err));
async function connectRedis() {
await client.connect();
console.log("Connected to Redis");
}
connectRedis();
module.exports = client;
What does this code do?
- createClient({ url: "redis://localhost:6379" }) creates a Redis client pointed at your Docker container.
- client.on("error", ...) logs any connection errors so you know immediately if Redis goes down.
- connectRedis() opens the connection and logs a success message when it completes.
- module.exports = client exports the connected client so other files can use the same connection.
- Save redisClient.js.
- Create another new file called server.js by right-clicking in the Explorer sidebar and selecting New File.
- Add the following code to server.js:
const client = require("./redisClient");
// Server entry point - Redis connection verified
- Save server.js.
Before you run this, what do you think will happen when server.js imports redisClient.js?
- Test the connection by running this in your VS Code terminal (Ctrl+` to open it):
node server.js
You should see Connected to Redis printed in your terminal. Your Node.js app is now talking to the Redis server inside your Docker container.
Seeing 'Redis Client Error'?
- Make sure your Redis container is still running. Run docker ps and check for the redis container in the list.
- If the container stopped, restart it with docker start redis.
- Verify the URL in redisClient.js is exactly redis://localhost:6379 with no typos.
- help me debug my Redis connection error in Node.js
- Press Ctrl+C in the terminal to stop the process.
✔️ Awesome, I've got everything!
Great. Make sure both redisClient.js and server.js are saved before continuing.
ⓧ I'd like to double check the full code
Here is the complete code for both files at this point.
const { createClient } = require("redis");
const client = createClient({
url: "redis://localhost:6379",
});
client.on("error", (err) => console.log("Redis Client Error", err));
async function connectRedis() {
await client.connect();
console.log("Connected to Redis");
}
connectRedis();
module.exports = client;
const client = require("./redisClient");
// Server entry point - Redis connection verified
Your Redis server is running and your Node.js app can talk to it. Next up, you'll build the product API endpoints that are painfully slow on purpose.
Build the Slow Product API
Your Redis server is running and your project is wired up. Now it's time to build the API that will eventually benefit from Redis.
Before you can appreciate what Redis does, you need to feel the pain of life without it. In this step, you'll build product endpoints that simulate expensive database queries with a deliberate 200ms delay on every single request.
In this step, get ready to:
- Create a simulated database module with product data and artificial delay.
- Set up Express routes that serve product JSON.
- Observe the consistent 200ms+ response time that never improves, no matter how many times you request the same data.
Create a simulated database module
A real database call takes time because it reads from disk, runs a query, and returns results over a network connection. To keep this project focused on Redis patterns, you'll simulate that delay with a simple in-memory array and a 200ms setTimeout.
- Create a new file called db.js in your project folder by adding the following code:
// Simulated product database
const products = [
{ id: "1", name: "Wireless Headphones", price: 79.99, category: "Electronics" },
{ id: "2", name: "Running Shoes", price: 129.99, category: "Sports" },
{ id: "3", name: "Coffee Maker", price: 49.99, category: "Kitchen" },
{ id: "4", name: "Backpack", price: 59.99, category: "Travel" },
{ id: "5", name: "Desk Lamp", price: 34.99, category: "Office" },
];
// Simulate a slow database lookup (200ms delay)
function getProductById(id) {
return new Promise((resolve) => {
setTimeout(() => {
const product = products.find((p) => p.id === id);
resolve(product || null);
}, 200);
});
}
// Simulate a slow query for all products (200ms delay)
function getAllProducts() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(products);
}, 200);
});
}
module.exports = { getProductById, getAllProducts };
What does this code do?
- products is a hardcoded array of five product objects, each with an id, name, price, and category.
- getProductById wraps a setTimeout of 200ms to mimic a slow database read, then finds the matching product by ID.
- getAllProducts does the same 200ms delay but returns the entire array.
- Both functions return Promises, just like a real database driver would.
- Save db.js.
- Confirm the file exists by running:
ls
You should see db.js listed alongside redisClient.js, server.js, and node_modules.
Don't see db.js in the output?
- Make sure you saved the file in the same folder as server.js and redisClient.js.
- Check that the filename is exactly db.js (lowercase, no spaces).
- help me find where db.js was saved
Create Express routes for products
Now you need routes that call your simulated database and return the results as JSON. You'll also log how long each request takes so the slowness is visible in your terminal.
- Replace the entire contents of server.js with the following code to set up Express and the single-product route:
const express = require("express");
const client = require("./redisClient");
const { getProductById, getAllProducts } = require("./db");
const app = express();
app.use(express.json());
// GET a single product by ID
app.get("/products/:id", async (req, res) => {
const start = Date.now();
const { id } = req.params;
const product = await getProductById(id);
if (!product) return res.status(404).json({ error: "Product not found" });
const duration = Date.now() - start;
console.log(`DB QUERY - product:${id} - ${duration}ms`);
res.json(product);
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
- express.json() is middleware that parses incoming JSON request bodies.
- The GET /products/:id route captures the product ID from the URL, calls your slow getProductById function, and returns the result.
- Date.now() before and after the database call measures the response time so you can see the delay in your terminal logs.
- app.listen starts the server on port 3000.
- Save server.js and start the server by running:
node server.js
You should see two lines in your terminal: Connected to Redis and Server running on http://localhost:3000.
- Open a second terminal window and test the route by running:
curl.exe http://localhost:3000/products/1
You should see the Wireless Headphones product returned as JSON. Back in your first terminal, you'll see a log line like DB QUERY - product:1 - 201ms.
Seeing an error when you start the server?
- If you see Cannot find module './db', make sure db.js is saved in the same folder as server.js.
- If port 3000 is already in use, stop any other process using that port and try again.
- help me debug my server startup error
- Stop the server in your first terminal by pressing Ctrl+C.
- Now add the route for all products. In server.js, find the line that says const PORT = 3000; and paste the following code directly above it:
// GET all products
app.get("/products", async (req, res) => {
const start = Date.now();
const products = await getAllProducts();
const duration = Date.now() - start;
console.log(`DB QUERY - products:all - ${duration}ms`);
res.json(products);
});
What does this route do?
This route returns every product in the array. It uses the same timing pattern so you can compare single-product versus all-products response times in your logs.
- Save server.js and restart the server:
node server.js
- In your second terminal, test the all-products route by running:
curl.exe http://localhost:3000/products
You should see all five products returned as a JSON array. Your first terminal logs DB QUERY - products:all - 200ms (or similar).
Getting an empty response or error?
- Make sure you placed the new route ABOVE the const PORT = 3000; line. Routes declared after app.listen won't work.
- Check that you have a blank line between the closing }); of the first route and the new app.get("/products", ...) line.
- help me fix my /products route
✔️ Awesome, I've got everything!
Great. Make sure both db.js and server.js are saved before moving on.
ⓧ I'd like to double check the full code
Here is db.js in full:
const products = [
{ id: "1", name: "Wireless Headphones", price: 79.99, category: "Electronics" },
{ id: "2", name: "Running Shoes", price: 129.99, category: "Sports" },
{ id: "3", name: "Coffee Maker", price: 49.99, category: "Kitchen" },
{ id: "4", name: "Backpack", price: 59.99, category: "Travel" },
{ id: "5", name: "Desk Lamp", price: 34.99, category: "Office" },
];
function getProductById(id) {
return new Promise((resolve) => {
setTimeout(() => {
const product = products.find((p) => p.id === id);
resolve(product || null);
}, 200);
});
}
function getAllProducts() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(products);
}, 200);
});
}
module.exports = { getProductById, getAllProducts };
And here is server.js in full:
const express = require("express");
const client = require("./redisClient");
const { getProductById, getAllProducts } = require("./db");
const app = express();
app.use(express.json());
app.get("/products/:id", async (req, res) => {
const start = Date.now();
const { id } = req.params;
const product = await getProductById(id);
if (!product) return res.status(404).json({ error: "Product not found" });
const duration = Date.now() - start;
console.log(`DB QUERY - product:${id} - ${duration}ms`);
res.json(product);
});
app.get("/products", async (req, res) => {
const start = Date.now();
const products = await getAllProducts();
const duration = Date.now() - start;
console.log(`DB QUERY - products:all - ${duration}ms`);
res.json(products);
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Test the slow experience
Both routes work, but how does the API behave when you hit the same endpoint repeatedly? In a real application, the same product gets requested thousands of times per minute.
- Before you run the next command, predict: will the second request for the same product be any faster than the first?
- In your second terminal, hit the same endpoint three times in a row by running:
curl.exe http://localhost:3000/products/1
curl.exe http://localhost:3000/products/1
curl.exe http://localhost:3000/products/1
Now look at your server terminal. You'll see three log lines, each showing roughly 200ms:
DB QUERY - product:1 - 201ms
DB QUERY - product:1 - 200ms
DB QUERY - product:1 - 201ms
Every request takes the same 200ms+ no matter how many times you ask for the exact same product. The server has no memory of previous requests.
Why is this a problem?
In production, popular products get requested thousands of times per second. If every request hits the database, you're wasting 200ms (or more) re-computing a result that hasn't changed.
This is the exact problem that Redis caching solves. In the next step, you'll store responses in Redis so the second request returns in under 5ms.
Your API works, but it's painfully slow on repeat requests. Next up, you'll add Redis caching to make the second request for the same product return in under 5ms.
Add Redis Caching
Your API works, but it has a painful problem. Every single request takes 200ms+ because it hits the simulated database every time, even when the same product is requested over and over.
The cache-aside pattern solves this by checking Redis first. If the data is already stored, Redis returns it in under 5ms. If not, the route fetches from the database and stores the result in Redis with a TTL (time to live) so future requests are lightning fast.
In this step, get ready to:
- Implement cache-aside logic in the single product route.
- Apply the same caching pattern to the all-products route.
- Test caching behavior and observe the speed difference.
Implement the cache-aside logic in the route handler
The cache-aside pattern follows a simple flow: check the cache first, return immediately if found, otherwise fetch from the database and store the result in the cache for next time.
- In server.js, find the app.get("/products/:id", ...) route handler. It currently looks like this:
app.get("/products/:id", async (req, res) => {
const start = Date.now();
const { id } = req.params;
const product = await getProductById(id);
if (!product) return res.status(404).json({ error: "Product not found" });
const duration = Date.now() - start;
console.log(`DB QUERY - product:${id} - ${duration}ms`);
res.json(product);
});
- Replace the entire route handler with this version that checks Redis before hitting the database:
app.get("/products/:id", async (req, res) => {
const start = Date.now();
const { id } = req.params;
const cacheKey = `product:${id}`;
try {
const cached = await client.get(cacheKey);
if (cached) {
const duration = Date.now() - start;
console.log(`CACHE HIT - product:${id} - ${duration}ms`);
res.set("X-Cache", "HIT");
return res.json(JSON.parse(cached));
}
const product = await getProductById(id);
if (!product) return res.status(404).json({ error: "Product not found" });
await client.setEx(cacheKey, 30, JSON.stringify(product));
const duration = Date.now() - start;
console.log(`CACHE MISS - product:${id} - ${duration}ms`);
res.set("X-Cache", "MISS");
res.json(product);
} catch (err) {
console.error("Error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
What does this code do?
This implements the cache-aside pattern in three stages:
- client.get(cacheKey) checks Redis for a cached copy of the product. If found, it parses the JSON and returns immediately.
- If nothing is cached (a MISS), the route fetches from the database as before. Then client.setEx(cacheKey, 30, ...) stores the result in Redis with a 30-second TTL.
- The X-Cache response header tells the client whether the response came from cache (HIT) or the database (MISS).
- Save server.js.
- Stop your running server with Ctrl+C, then restart it by running:
node server.js
- Open a second terminal and hit the endpoint by running:
curl http://localhost:3000/products/1
Check your server terminal. You should see a CACHE MISS log with ~200ms response time.
- Run the same command again:
curl http://localhost:3000/products/1
This time you should see CACHE HIT with a response time under 5ms. The speed difference is dramatic.
Still seeing DB QUERY instead of CACHE HIT/MISS?
- Make sure you replaced the entire route handler, not just part of it. The old DB QUERY log line should be gone.
- Confirm your Redis container is still running with docker ps. If it is not listed, start it with docker start redis.
- Check that you saved the file before restarting the server.
Help me debug my Redis caching issue
Add caching to the all-products route
The same cache-aside pattern applies to the all-products endpoint. Every time someone requests the full product list, the database takes 200ms. Caching fixes that too.
- In server.js, find the app.get("/products", ...) route handler. It currently looks like this:
app.get("/products", async (req, res) => {
const start = Date.now();
const products = await getAllProducts();
const duration = Date.now() - start;
console.log(`DB QUERY - products:all - ${duration}ms`);
res.json(products);
});
- Replace the entire route handler with this cached version:
app.get("/products", async (req, res) => {
const start = Date.now();
const cacheKey = "products:all";
try {
const cached = await client.get(cacheKey);
if (cached) {
const duration = Date.now() - start;
console.log(`CACHE HIT - products:all - ${duration}ms`);
res.set("X-Cache", "HIT");
return res.json(JSON.parse(cached));
}
const products = await getAllProducts();
await client.setEx(cacheKey, 30, JSON.stringify(products));
const duration = Date.now() - start;
console.log(`CACHE MISS - products:all - ${duration}ms`);
res.set("X-Cache", "MISS");
res.json(products);
} catch (err) {
console.error("Error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
What's different about this route?
The pattern is identical to the single-product route, but the cache key is the fixed string products:all instead of a dynamic product:{id}. One key stores the entire list.
- Save server.js.
- Stop the server with Ctrl+C and restart it by running:
node server.js
- In your second terminal, test the all-products endpoint by running:
curl http://localhost:3000/products
You should see CACHE MISS with ~200ms in the server terminal. Run it again and you should see CACHE HIT under 5ms.
Getting a connection error?
- If you see a connection refused error, make sure the server restarted successfully. Check the terminal for the Connected to Redis and Server running on http://localhost:3000 messages.
- If you see a JSON parse error, double check that JSON.stringify(products) is used when storing and JSON.parse(cached) when reading.
Help me debug my /products caching issue
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js and that both routes are using the cache-aside pattern.
ⓧ I'd like to double check the full code
Your server.js should now contain these sections in order:
- Three require statements at the top (express, ./redisClient, ./db).
- Express app setup with express.json() middleware.
- The GET /products/:id route with cache-aside (the first route handler you replaced above).
- The GET /products route with cache-aside (the second route handler you replaced above).
- The app.listen block at the bottom on port 3000.
Scroll up and confirm each code block matches what you pasted. Both route handlers should have client.get(), client.setEx(), and the X-Cache header.
Test the TTL expiration
Now that both routes are cached, you can see the full impact. The real test is watching what happens when the TTL expires after 30 seconds.
- Make sure your server is running. In your second terminal, hit the single product endpoint by running:
curl http://localhost:3000/products/1
Before you run the same request again, what do you think the response time will be this time? Will it still take 200ms, or will something different happen?
- Run the request a second time:
curl http://localhost:3000/products/1
Check your server terminal. The first request logs CACHE MISS with ~200ms. The second logs CACHE HIT with ~2ms. That is a 100x speed improvement from a single Redis lookup.
What is TTL expiration?
The 30-second TTL you set with setEx means Redis automatically deletes the cached value after 30 seconds. This prevents serving stale data forever.
After the TTL expires, the next request becomes a CACHE MISS again. It fetches fresh data from the database and re-caches it for another 30 seconds.
- Wait 30 seconds, then run the same request one more time:
curl http://localhost:3000/products/1
Your terminal should now show another CACHE MISS. Redis deleted the expired key, so the route fetched from the database again and stored a fresh copy.
Not seeing the TTL expire after 30 seconds?
- Make sure you are waiting a full 30 seconds before retrying. The TTL is exact.
- Confirm the second argument to client.setEx() is 30 (the TTL in seconds).
Help me debug my TTL configuration
Your API now serves cached responses in under 5ms instead of 200ms. Next up, you will protect this fast API from abuse by adding rate limiting with Redis atomic counters.
Add Rate Limiting with Redis Counters
Your cached API is fast. Responses fly back in under 5ms on a cache hit.
But there is nothing stopping a single client from slamming your API with thousands of requests per second. Without protection, one misbehaving script could overwhelm your server and ruin the experience for everyone else. In this step, you will use Redis atomic counters to build a rate limiter that tracks requests per IP and blocks anyone who exceeds the limit.
In this step, get ready to:
- Create a rate limiter middleware that uses Redis INCR and EXPIRE to count requests per IP.
- Mount the middleware on all routes and add rate limit headers to every response.
- Test rate limiting by hammering the API past its threshold.
Create the rate limiter middleware
The strategy is called fixed-window rate limiting. Each IP address gets a counter in Redis. Every request increments the counter. The first request also sets a 60-second expiry on the key. Once the counter exceeds 10, the server rejects further requests until the window resets.
- Create a new file called rateLimiter.js in your project folder.
- Add the imports, constants, and the opening of the rate limiter function by pasting this code:
const client = require("./redisClient");
const WINDOW_SECONDS = 60;
const MAX_REQUESTS = 10;
async function rateLimiter(req, res, next) {
const ip = req.ip;
const key = `ratelimit:${ip}`;
try {
// Increment the counter for this IP
const currentCount = await client.incr(key);
// On the first request, set the window expiry
if (currentCount === 1) {
await client.expire(key, WINDOW_SECONDS);
}
const ttl = await client.ttl(key);
What does this code do?
This is the heart of the rate limiter. Here is what each piece accomplishes:
- client.incr(key) atomically increments a Redis counter for this IP. If the key does not exist, Redis creates it and sets it to 1.
- client.expire(key, WINDOW_SECONDS) sets the key to self-destruct after 60 seconds. This only fires on the first request (when currentCount === 1) so the window does not keep resetting.
- client.ttl(key) reads how many seconds remain before the key expires. You will use this to tell clients when their window resets.
- Continue in the same file. Paste the following code directly below the const ttl line to complete the function:
res.set("X-RateLimit-Limit", String(MAX_REQUESTS));
res.set("X-RateLimit-Remaining", String(Math.max(0, MAX_REQUESTS - currentCount)));
res.set("X-RateLimit-Reset", String(ttl));
if (currentCount > MAX_REQUESTS) {
return res.status(429).json({
error: "Too Many Requests",
message: `Rate limit exceeded. Try again in ${ttl} seconds.`,
});
}
next();
} catch (err) {
console.error("Rate limiter error:", err);
next();
}
}
module.exports = rateLimiter;
What does this second half do?
This section handles the response. It sets informative headers on every response so clients can track their quota. If the request count exceeds 10, it returns HTTP 429 with a message telling the client how many seconds to wait.
The catch block calls next() even on errors. This means a Redis failure degrades gracefully instead of crashing the request.
- Save rateLimiter.js.
- Confirm the file exists by running:
ls rateLimiter.js
You should see rateLimiter.js printed in the output.
File not found?
- Make sure you saved the file in the same folder as server.js and redisClient.js.
- Check that the filename is exactly rateLimiter.js (capital L, no spaces).
Still stuck? Why can't Node find my rateLimiter.js file?
Apply the middleware to your server
The middleware is ready. Now you need to import it into server.js and mount it so every incoming request passes through the rate limiter before reaching your routes.
- In server.js, find this line near the top:
const { getProductById, getAllProducts } = require("./db");
- Add this import directly below it:
const rateLimiter = require("./rateLimiter");
- Now find this line:
app.use(express.json());
- Add this line directly below it to mount the rate limiter on all routes:
app.use(rateLimiter);
Why mount it before the routes?
Express middleware runs in the order it is registered. By placing app.use(rateLimiter) before the route handlers, every request hits the rate limiter first. If the client is over the limit, the 429 response fires and the route handler never executes.
- Save server.js.
- Restart your server by running:
node server.js
You should see Connected to Redis and Server running on http://localhost:3000 printed in your terminal.
Seeing a require error?
- If you see Cannot find module './rateLimiter', double check that the filename is spelled exactly rateLimiter.js and lives in the same folder as server.js.
- If you see a syntax error, make sure rateLimiter.js has both opening and closing braces for the function and the try/catch block.
Need help? My server crashes when I add the rateLimiter middleware.
Test rate limiting by hammering the API
Time to see the rate limiter in action. You will send more than 10 requests in rapid succession and watch the 429 response kick in.
- Open a second terminal (keep your server running in the first one).
- Hit the product endpoint once to confirm it still works by running:
curl http://localhost:3000/products/1
You should see the product JSON returned. Notice the response also includes rate limit headers now.
- Before you run the next command, predict: what do you think will happen on the 11th request?
- Hammer the API past the limit by running this loop:
for i in $(seq 1 12); do curl -s http://localhost:3000/products/1 | head -c 80; echo; done
On Windows PowerShell?
If you are using PowerShell instead of Git Bash, run this equivalent command:
1..12 | ForEach-Object { Invoke-RestMethod http://localhost:3000/products/1 }
The first 10 requests return product data. Requests 11 and 12 return a 429 response with a message telling you how many seconds until the window resets.
What are the X-RateLimit headers?
Every response includes three headers that tell the client their quota status:
- X-RateLimit-Limit: the maximum requests allowed per window (10).
- X-RateLimit-Remaining: how many requests the client has left.
- X-RateLimit-Reset: seconds until the window resets and the counter clears.
- Wait 60 seconds for the window to reset, then run a single request to confirm you are no longer blocked:
curl http://localhost:3000/products/1
You should see the product JSON returned successfully again. The rate limit counter has expired and you are back to a fresh window.
Not seeing 429 after 10 requests?
- If all 12 requests succeed, the rate limiter middleware may not be mounted. Check that app.use(rateLimiter) appears before your route handlers in server.js.
- If you restarted your server between requests, the Redis counter may have been reset by a fresh window. Run the loop all at once without restarting.
- Verify Redis is still running with docker ps. If the container is not listed, start it again with docker start redis.
Still stuck? Why is my rate limiter not blocking requests after 10?
✔️ Awesome, I've got everything!
Great. Make sure you have saved both rateLimiter.js and server.js.
ⓧ I'd like to double check the full code
Scroll up and confirm your files contain these sections in order:
- rateLimiter.js: The first chunk (imports, constants, client.incr, client.expire, client.ttl) followed by the second chunk (header res.set calls, the 429 check, next(), catch block, and module.exports).
- server.js: The const rateLimiter = require("./rateLimiter") import near the top, and app.use(rateLimiter) immediately after app.use(express.json()).
Your API is now fast and protected. Any client that crosses 10 requests in a minute gets shut down. Next up, you will add session-based authentication so only logged-in users can access the product data at all.
Add Session Storage with Redis
Your API is fast thanks to caching, and protected from abuse by the rate limiter. But right now, anyone can access the product data without identifying themselves.
Adding Redis-backed sessions means users must log in first. Their session data persists across server restarts because it lives in Redis, not in server memory.
In this step, get ready to:
- Configure express-session with a Redis store so sessions survive restarts.
- Create login and logout routes that write and destroy sessions.
- Protect product routes with an auth middleware that rejects unauthenticated requests.
Configure express-session with RedisStore
The express-session package handles session cookies automatically. connect-redis plugs into it so session data is stored in Redis instead of your server's memory.
- In server.js, find the imports at the top of the file.
- Add these two new import lines directly after const express = require("express");:
const session = require("express-session");
const { RedisStore } = require("connect-redis");
- Now find this line in server.js:
app.use(rateLimiter);
- Add the following session configuration code directly above app.use(rateLimiter); so that session middleware runs before rate limiting:
// Session configuration with Redis store
const redisStore = new RedisStore({
client: client,
prefix: "session:",
});
app.use(
session({
store: redisStore,
resave: false,
saveUninitialized: false,
secret: "your-secret-key-here",
cookie: {
httpOnly: true,
maxAge: 1800000, // 30 minutes
},
})
);
What does this code do?
This sets up a session store backed entirely by Redis.
- RedisStore tells express-session to save session data in Redis under keys prefixed with session:.
- resave: false prevents re-saving sessions that haven't changed, reducing unnecessary Redis writes.
- maxAge: 1800000 sets the session cookie to expire after 30 minutes of inactivity.
- httpOnly: true prevents JavaScript in the browser from reading the cookie, which protects against cross-site scripting attacks.
- Save server.js.
- Restart your server by pressing Ctrl+C to stop it, then running:
node server.js
You should see Connected to Redis and Server running on http://localhost:3000 with no errors. The session middleware is active.
Seeing an error about RedisStore?
Make sure you imported RedisStore with curly braces: const { RedisStore } = require("connect-redis"). It is a named export, not a default export.
Also confirm connect-redis was installed in Step 1. Run npm install connect-redis express-session if needed.
help me debug my RedisStore import error
Create login and logout routes
With sessions configured, you need routes that let users create and destroy sessions. A login route stores the username in the session, and a logout route wipes it.
- In server.js, find app.use(rateLimiter); and add the following code below it:
// Login route
app.post("/login", (req, res) => {
const { username } = req.body;
if (!username) {
return res.status(400).json({ error: "Username is required" });
}
req.session.user = { username, loginTime: new Date().toISOString() };
res.json({ message: `Welcome, ${username}! You are now logged in.` });
});
// Logout route
app.post("/logout", (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: "Failed to log out" });
}
res.json({ message: "Logged out successfully" });
});
});
What does this code do?
- POST /login reads the username from the request body and saves it to req.session.user. Express-session automatically creates a session cookie and stores the data in Redis.
- POST /logout calls req.session.destroy() which removes the session from Redis entirely. The cookie becomes invalid.
- Save server.js and restart the server with Ctrl+C then:
node server.js
- Test the login route by running this command in a new terminal:
curl -X POST http://localhost:3000/login -H "Content-Type: application/json" -d '{"username":"testuser"}'
Using PowerShell?
If you are in PowerShell, use curl.exe instead of curl to call the real curl binary. PowerShell aliases curl to a different command.
You should see a JSON response: {"message":"Welcome, testuser! You are now logged in."}.
Getting a 400 error?
A 400 response means the request body is missing the username field. Make sure your curl command includes -H "Content-Type: application/json" and a valid JSON body with -d.
help me fix my curl POST request to the login route
Protect product routes with an auth middleware
The login route works, but the product endpoints are still wide open. A middleware function that checks for a valid session will block unauthenticated users with a 401 response.
- In server.js, add the following auth middleware between app.use(rateLimiter); and the login route you just created:
// Auth middleware
function requireAuth(req, res, next) {
if (!req.session.user) {
return res.status(401).json({ error: "Unauthorized. Please log in first." });
}
next();
}
What does this code do?
The requireAuth function checks if req.session.user exists. If the user has not logged in, express-session will not have populated this field, so the middleware immediately returns a 401. If the session is valid, next() passes control to the route handler.
- Now find the GET /products/:id route. It currently looks like this:
app.get("/products/:id", async (req, res) => {
- Add requireAuth as a middleware argument so the line now reads:
app.get("/products/:id", requireAuth, async (req, res) => {
- Do the same for the GET /products route. Find:
app.get("/products", async (req, res) => {
- Change it to:
app.get("/products", requireAuth, async (req, res) => {
- Save server.js and restart the server.
node server.js
Before you test, what do you think will happen if you hit GET /products/1 without logging in first?
- Test an unauthenticated request by running:
curl http://localhost:3000/products/1
You should see {"error":"Unauthorized. Please log in first."} with a 401 status. The auth middleware blocked you.
- Now log in and save the session cookie to a file by running:
curl -X POST http://localhost:3000/login -H "Content-Type: application/json" -d '{"username":"testuser"}' -c cookies.txt
- Hit the product endpoint again, this time sending the saved cookie:
curl http://localhost:3000/products/1 -b cookies.txt
This time you see the full product JSON response. The session cookie proved your identity.
- Finally, test logout and confirm the session is destroyed:
curl -X POST http://localhost:3000/logout -b cookies.txt
- Try the product endpoint one more time with the old cookie:
curl http://localhost:3000/products/1 -b cookies.txt
You are back to 401 Unauthorized. The session was destroyed in Redis, so the cookie is no longer valid.
Still getting product data after logout?
Make sure your logout curl command includes -b cookies.txt so the server knows which session to destroy. Without the cookie, the server cannot identify your session.
Also check that your POST /logout route calls req.session.destroy() and not just req.session.user = null. The destroy() method removes the session from Redis entirely.
help me debug why logout is not clearing my session
Why store sessions in Redis instead of memory?
By default, express-session stores sessions in your server's RAM. If the server restarts, all sessions are lost and every user is logged out instantly.
With Redis as the session store, sessions survive server restarts. They also work across multiple server instances because every instance reads from the same Redis. This is how production apps handle sessions at scale.
✔️ Awesome, I've got everything!
Great. Double check that you have saved server.js and that all your tests passed.
ⓧ I'd like to double check the full code
Your server.js is now too long to show in one block. Scroll up and confirm these sections appear in this exact order:
- Imports: express, express-session, RedisStore from connect-redis, client, db functions, rateLimiter.
- App setup: express.json() middleware.
- Session config: RedisStore instance and session() middleware with resave: false, saveUninitialized: false, secret, and cookie config.
- Rate limiter: app.use(rateLimiter).
- Auth middleware: requireAuth function checking req.session.user.
- Routes: POST /login, POST /logout, GET /products/:id with requireAuth, GET /products with requireAuth.
- Server listen on port 3000.
Your API now requires authentication. Users must log in, their session lives in Redis, and logging out destroys it completely. Next up, you will tackle cache invalidation in the Secret Mission.
Secret mission
Add Cache Invalidation on Product Updates
Your caching layer is fast, but what happens when product data changes? Right now, users would see stale information until the TTL expires. In this mission, you'll add a PUT route that updates products and proactively clears the cache so users always see fresh data.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- A Docker container named redis running on port 6379 (no cost, local only).
- The redis-product-api project folder on your Desktop with Node.js dependencies.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with Redis commands.
- The Docker container uses minimal system resources while running.
- Your cached data, sessions, and rate limit counters persist in Redis as long as the container is running.
- You can restart your Node.js server anytime with node server.js from inside the project folder.
Pause - I'll come back to this later
Shut down running processes to free up memory while keeping your files and container intact for later.
- Stop the Node.js server by pressing Ctrl+C in the terminal where it is running.
- Stop the Redis container by running:
docker stop redis
- When you are ready to come back, restart the container by running:
docker start redis
Your Redis data persists between stop and start cycles.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Stop the Node.js server by pressing Ctrl+C in the terminal where it is running (if it is still active).
- Remove the Redis Docker container by running:
docker rm -f redis
- Delete the project folder. On macOS or Linux, run:
rm -rf ~/Desktop/redis-product-api
- On Windows (PowerShell), run:
Remove-Item -Recurse -Force ~\Desktop\redis-product-api
Your Docker Desktop installation and Node.js remain on your machine for future projects.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade Express API that uses Redis for three distinct real-world patterns: caching, rate limiting, and session storage.
You've learned how to:
- Implement the cache-aside pattern with Redis, turning 200ms+ database responses into sub-5ms cached responses using client.get(), client.setEx(), and TTL expiration.
- Build a fixed-window rate limiter using Redis atomic counters (INCR and EXPIRE) that blocks abusive clients with HTTP 429 responses.
- Store user sessions in Redis using connect-redis so authentication persists across server restarts without relying on server memory.
- Secret Mission: Added cache invalidation using client.del() to ensure users always see fresh data immediately after product updates.
Ready to quiz yourself?