Redis Caching in Node.js and Express: Complete Guide with MongoDB
![]() |
| Redis Caching in Node.js and Express with MongoDB |
Redis caching in Node.js and Express is one of the most practical ways to reduce repeated database queries, expensive computations, and unnecessary load on backend services. When an API repeatedly serves the same or similar data, a properly designed Redis cache can allow the application to return frequently requested results without repeating the original database operation on every request.
For example, imagine an Express API that returns a popular product list from MongoDB. Without caching, every request may execute essentially the same database query. With Redis caching, the application can check Redis first. If the requested data is already cached, the API can return it immediately. If the cache entry does not exist, the application queries MongoDB, stores the result in Redis with a defined expiration time, and then returns the result to the client.
This article explains how to implement Redis caching in Node.js and Express using the modern redis package, MongoDB, Mongoose, and the cache-aside pattern. It covers cache hits and misses, TTL, cache-key design, reusable cache helpers, invalidation after writes, authenticated data, Redis failures, cache stampedes, negative caching, monitoring, memory management, security, performance testing, and production deployment considerations.
The objective is not simply to make an endpoint appear faster. A production caching design must also protect data correctness, prevent cross-user data exposure, handle Redis failures gracefully, avoid uncontrolled memory growth, and remain understandable as the application grows.
In this guide:
- What Redis caching is
- Why use Redis with Node.js and Express
- The cache-aside pattern
- Project setup
- Connecting Node.js to Redis
- Connecting MongoDB with Mongoose
- Building a reusable cache helper
- Building cached Express routes
- Cache invalidation
- Choosing a Redis TTL
- Designing reliable cache keys
- Caching authenticated data safely
- Preventing cache stampedes
- Handling Redis failures
- Redis security
- Measuring performance
- Production best practices
- Troubleshooting
- Frequently asked questions
Redis Caching in Node.js and Express: Quick Answer
Redis caching works by placing a fast server-side storage layer in front of a database or expensive operation.
Client
↓
Express API
↓
Check Redis
↓
Cache hit? ── Yes ──→ Return cached response
│
No
↓
Query MongoDB
↓
Store result in Redis
↓
Return response
This pattern is commonly called cache-aside. The application is responsible for checking the cache and loading data from the primary source when the cache does not contain a usable value.
In the architecture used throughout this tutorial, MongoDB remains the source of truth while Redis stores temporary copies used to improve read performance.
What Is Redis Caching?
What Is Redis?
Redis is an in-memory data store that provides key-value access and a range of specialized data structures. It is commonly used for caching, counters, sessions, queues, rate limiting, distributed coordination, and other low-latency workloads.
For a simple cache, the mental model is:
Key → Value
For example:
products:list → [{"id":"1","name":"Laptop","price":899}]
A Node.js application can retrieve the cached value using the key instead of repeating the original database query.
For Node.js and JavaScript applications, Redis documents node-redis as its recommended Redis client. The package can be installed with:
npm install redis
What Is Caching?
A cache is a temporary storage layer placed between an application and a slower or more expensive source of data.
Suppose an Express API executes this MongoDB query on every request:
const products = await Product.find({ active: true })
.sort({ createdAt: -1 })
.lean();
If the same product list is requested thousands of times, the database may perform substantially the same work thousands of times.
With Redis caching, the flow becomes:
Request
↓
Redis GET
↓
Cache hit?
├── Yes → Return cached value
│
└── No
↓
MongoDB query
↓
Redis SET + TTL
↓
Return value
Cache Hit
A cache hit happens when Redis contains a usable value for the requested key.
Cache Miss
A cache miss happens when the key does not exist, has expired, or otherwise cannot be used. The application then loads the data from its original source.
TTL
TTL means Time To Live. It determines how long a cache entry remains available before it expires.
For example, a TTL of 300 seconds means that Redis will automatically expire the key after five minutes unless the key is replaced or its expiration is changed.
Stale Data
Cached data is stale when the underlying source has changed but the cache still contains an older representation.
That means caching is both a performance decision and a data-freshness decision.
Cache Invalidation
Cache invalidation removes or updates cached data after the underlying data changes.
MongoDB product updated
↓
Delete products:id:123
↓
Next GET
↓
Load current value
↓
Store fresh value in Redis
TTL provides automatic expiration, while explicit invalidation gives the application more control when freshness matters.
Why Use Redis With Node.js and Express?
Redis is particularly useful when an API repeatedly returns the same data or repeats an expensive operation.
Repeated Database Queries
A popular GET endpoint can generate many identical database reads. Caching can absorb a large portion of those reads when the underlying data changes less frequently than it is requested.
Expensive Computation
Some responses require aggregation pipelines, report generation, external API calls, recommendation calculations, or significant application-side processing. Caching the final result can eliminate repeated work.
High Traffic
As traffic grows, caching can reduce pressure on database connections, CPU, storage, and downstream services.
Horizontal Scaling
If several Node.js processes or containers run behind a load balancer, an in-process cache exists separately in each process. Redis can provide a shared cache that multiple application instances can use.
When Redis Caching May Not Be Worth It
Redis is not automatically the correct choice for every endpoint.
Caching may provide little value when:
- the data is requested very rarely;
- the underlying data changes almost every time it is requested;
- the response is highly personalized;
- the original operation is already inexpensive;
- a browser cache or CDN can solve the problem more effectively;
- the operational overhead of Redis is greater than the performance benefit.
A strong caching strategy starts with a measured bottleneck rather than adding Redis simply because it is a popular technology.
Understanding the Cache-Aside Pattern
The main architecture in this tutorial is the cache-aside pattern.
![]() |
| Figure 1. Redis Cache-Aside Pattern in a Node.js and Express API |
┌───────────────┐
│ Client │
└───────┬───────┘
│
▼
┌───────────────┐
│ Express API │
└───────┬───────┘
│
▼
┌───────────────┐
│ Redis │
└───────┬───────┘
│
┌──────┴──────┐
│ │
HIT MISS
│ │
▼ ▼
Return value MongoDB query
│
▼
Redis SET
│
▼
Response
The application, rather than Redis itself, decides when the database should be queried and when a cached value should be used.
The basic algorithm is:
- Build a deterministic cache key.
- Attempt to read that key from Redis.
- If a valid value exists, return it.
- If the key is missing, load the data from MongoDB.
- Store the result in Redis with a TTL.
- Return the result to the client.
This pattern is attractive because MongoDB remains the primary data source. If Redis is temporarily unavailable and caching is optional, the application can often continue against MongoDB.
Technology Stack
| Technology | Purpose |
|---|---|
| Node.js | JavaScript runtime for the backend |
| Express.js | HTTP API and middleware framework |
| Redis | Server-side cache and in-memory data store |
| MongoDB | Primary application database |
| Mongoose | MongoDB ODM |
| redis | Node.js Redis client |
| dotenv | Loads environment configuration during local development |
Mongoose provides schema definitions, validation, models, and database access patterns for MongoDB.
Prerequisites
Before beginning, you should have:
- a current Node.js LTS release;
- npm or another Node.js package manager;
- basic JavaScript knowledge;
- basic Express knowledge;
- basic MongoDB knowledge;
- a running Redis server;
- a running MongoDB instance or MongoDB Atlas cluster.
You do not need advanced Redis knowledge to follow the implementation.
Project Setup
Create a new project:
mkdir redis-express-cache
cd redis-express-cache
npm init -y
Install the required packages:
npm install express redis mongoose dotenv
Node-redis is the official Redis client documented for Node.js applications.
Configure ES Modules
Add "type": "module" to your existing package.json:
{
"name": "redis-express-cache",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js"
}
}
If your project already contains dependencies or scripts, do not delete them simply to match this example. Add or modify only the fields required by the tutorial.
Recommended Project Structure
redis-express-cache/
├── src/
│ ├── config/
│ │ ├── db.js
│ │ └── redis.js
│ ├── middleware/
│ │ └── cache.js
│ ├── models/
│ │ └── Product.js
│ └── routes/
│ └── productRoutes.js
├── .env
├── .gitignore
├── package.json
└── server.js
Run Redis Locally
For local development, Redis can be installed directly or started with Docker.
For example:
docker run --name redis-cache -p 6379:6379 -d redis
After Redis is running, test the connection:
redis-cli -h 127.0.0.1 -p 6379 ping
A healthy local Redis instance should respond with:
PONG
For production, use an appropriately secured Redis deployment rather than exposing a development instance to the public internet.
Configure Environment Variables
Create a .env file:
PORT=3000
MONGODB_URI=mongodb://127.0.0.1:27017/redis_cache_demo
REDIS_URL=redis://127.0.0.1:6379
A managed Redis service may provide a URL containing a hostname, port, username, password, database number, TLS configuration, or other connection parameters.
Never commit production credentials to source control.
Create .gitignore:
node_modules/
.env
Connect Node.js to Redis
Create:
src/config/redis.js
Then add:
import { createClient } from 'redis';
const redisClient = createClient({
url: process.env.REDIS_URL,
socket: {
connectTimeout: 5000
}
});
redisClient.on('error', (error) => {
console.error('[Redis] Client error:', error.message);
});
export async function connectRedis() {
if (redisClient.isOpen || redisClient.isReady) {
return true;
}
try {
await redisClient.connect();
console.log('[Redis] Connected.');
return true;
} catch (error) {
console.error(
'[Redis] Initial connection failed:',
error.message
);
return false;
}
}
export async function closeRedis() {
if (redisClient.isOpen) {
await redisClient.quit();
}
}
export default redisClient;
The Redis client should be created once and reused rather than creating a new connection for every HTTP request. The node-redis documentation exposes isReady and isOpen for connection state and documents quit() for clean shutdown.
The error listener is important because Redis client errors should remain observable rather than silently disappearing.
Connect MongoDB With Mongoose
Create:
src/config/db.js
Then:
import mongoose from 'mongoose';
export async function connectMongoDB() {
await mongoose.connect(process.env.MONGODB_URI);
console.log('[MongoDB] Connected.');
}
Mongoose provides an application-level modeling layer over MongoDB with schemas, validation, and models.
Create the Product Model
Create:
src/models/Product.js
Use:
import mongoose from 'mongoose';
const productSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
trim: true
},
price: {
type: Number,
required: true,
min: 0
},
active: {
type: Boolean,
default: true
}
},
{
timestamps: true
}
);
export default mongoose.model('Product', productSchema);
Basic Redis Cache Logic
Before creating reusable infrastructure, it is useful to see the simplest possible cache-aside flow:
const cacheKey = 'products:list';
const cached = await redisClient.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached);
}
const products = await Product.find({
active: true
})
.sort({ createdAt: -1 })
.lean();
await redisClient.set(
cacheKey,
JSON.stringify(products),
{
EX: 300
}
);
return products;
The EX option specifies an expiration time in seconds.
This is the core idea behind Redis caching. Production code then needs to handle failures, invalid values, key design, invalidation, observability, and application-specific data rules.
Build a Reusable Cache Helper
A common mistake in Express tutorials is to override res.json() globally inside cache middleware and silently intercept downstream responses. That approach can work, but it makes response behavior less explicit and introduces hidden coupling.
A cleaner design for this tutorial is to use:
- a small middleware that handles cache hits;
- a reusable helper that stores successful results;
- explicit cache population in the route handler.
This makes the cache lifecycle easier to understand and avoids replacing Express response methods.
Create the Cache Helper
Create:
src/middleware/cache.js
Add:
import redisClient from '../config/redis.js';
export function cacheMiddleware(
buildKey
) {
return async (req, res, next) => {
if (req.method !== 'GET') {
return next();
}
if (!redisClient.isReady) {
res.set('X-Cache', 'BYPASS');
return next();
}
const key = buildKey(req);
try {
const cached = await redisClient.get(key);
if (cached !== null) {
try {
res.set('X-Cache', 'HIT');
return res.json(JSON.parse(cached));
} catch (parseError) {
console.error(
'[Redis] Invalid cached JSON:',
parseError.message
);
await redisClient.del(key);
}
}
res.set('X-Cache', 'MISS');
res.locals.cacheKey = key;
return next();
} catch (error) {
console.error(
'[Redis] Cache read failed:',
error.message
);
res.set('X-Cache', 'BYPASS');
return next();
}
};
}
export async function setCache(
key,
value,
ttlSeconds
) {
if (!redisClient.isReady) {
return false;
}
try {
await redisClient.set(
key,
JSON.stringify(value),
{
EX: ttlSeconds
}
);
return true;
} catch (error) {
console.error(
'[Redis] Cache write failed:',
error.message
);
return false;
}
}
export async function deleteCache(key) {
if (!redisClient.isReady) {
return false;
}
try {
await redisClient.del(key);
return true;
} catch (error) {
console.error(
'[Redis] Cache delete failed:',
error.message
);
return false;
}
}
This middleware has an intentionally limited responsibility: it checks Redis and serves cache hits. On a miss, it allows the route handler to perform its normal work.
The route then explicitly calls setCache() after a successful database operation.
This design aligns naturally with Express's middleware model, where middleware can end the request or call next() to continue the request-response cycle.
Build the Product Routes
Create:
src/routes/productRoutes.js
Then add:
import express from 'express';
import Product from '../models/Product.js';
import redisClient from '../config/redis.js';
import {
cacheMiddleware,
setCache,
deleteCache
} from '../middleware/cache.js';
const router = express.Router();
const PRODUCT_LIST_TTL = 300;
const PRODUCT_ITEM_TTL = 300;
const productListKey = 'products:list';
const productItemKey = (id) =>
`products:id:${id}`;
router.get(
'/',
cacheMiddleware(() => productListKey),
async (req, res, next) => {
try {
const products = await Product.find({
active: true
})
.sort({ createdAt: -1 })
.lean();
await setCache(
productListKey,
products,
PRODUCT_LIST_TTL
);
return res.json(products);
} catch (error) {
return next(error);
}
}
);
router.get(
'/:id',
cacheMiddleware(
(req) => productItemKey(req.params.id)
),
async (req, res, next) => {
try {
const product = await Product.findById(
req.params.id
).lean();
if (!product) {
return res.status(404).json({
error: 'Product not found'
});
}
await setCache(
productItemKey(req.params.id),
product,
PRODUCT_ITEM_TTL
);
return res.json(product);
} catch (error) {
return next(error);
}
}
);
router.post('/', async (req, res, next) => {
try {
const {
name,
price,
active
} = req.body;
if (
typeof name !== 'string' ||
typeof price !== 'number'
) {
return res.status(400).json({
error:
'name and numeric price are required'
});
}
const product = await Product.create({
name,
price,
active: active ?? true
});
await deleteCache(productListKey);
return res.status(201).json(product);
} catch (error) {
return next(error);
}
});
router.put('/:id', async (req, res, next) => {
try {
const {
name,
price,
active
} = req.body;
const update = {};
if (name !== undefined) {
if (typeof name !== 'string') {
return res.status(400).json({
error: 'name must be a string'
});
}
update.name = name;
}
if (price !== undefined) {
if (
typeof price !== 'number' ||
price < 0
) {
return res.status(400).json({
error:
'price must be a non-negative number'
});
}
update.price = price;
}
if (active !== undefined) {
if (typeof active !== 'boolean') {
return res.status(400).json({
error: 'active must be a boolean'
});
}
update.active = active;
}
const product =
await Product.findByIdAndUpdate(
req.params.id,
update,
{
new: true,
runValidators: true
}
).lean();
if (!product) {
return res.status(404).json({
error: 'Product not found'
});
}
await Promise.all([
deleteCache(
productItemKey(req.params.id)
),
deleteCache(productListKey)
]);
return res.json(product);
} catch (error) {
return next(error);
}
});
router.delete('/:id', async (req, res, next) => {
try {
const product =
await Product.findByIdAndDelete(
req.params.id
).lean();
if (!product) {
return res.status(404).json({
error: 'Product not found'
});
}
await Promise.all([
deleteCache(
productItemKey(req.params.id)
),
deleteCache(productListKey)
]);
return res.status(204).send();
} catch (error) {
return next(error);
}
});
export default router;
Notice the important sequence:
GET
↓
Redis check
↓
HIT → return immediately
MISS
↓
MongoDB
↓
Redis SET + TTL
↓
Response
For writes:
POST / PUT / DELETE
↓
MongoDB mutation
↓
Invalidate affected Redis keys
↓
Return response
Complete Server Setup
Create:
server.js
Then:
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
import {
connectMongoDB
} from './src/config/db.js';
import redisClient, {
connectRedis,
closeRedis
} from './src/config/redis.js';
import productRoutes from './src/routes/productRoutes.js';
const app = express();
const port =
Number(process.env.PORT) || 3000;
app.disable('x-powered-by');
app.use(express.json());
app.get('/health', (req, res) => {
return res.json({
status: 'ok',
redis: redisClient.isReady
? 'ready'
: 'unavailable',
mongodb:
mongoose.connection.readyState === 1
? 'connected'
: 'unavailable'
});
});
app.use(
'/api/products',
productRoutes
);
app.use(
(error, req, res, next) => {
console.error(error);
if (res.headersSent) {
return next(error);
}
return res.status(500).json({
error: 'Internal server error'
});
}
);
async function startServer() {
await connectMongoDB();
const redisConnected =
await connectRedis();
if (!redisConnected) {
console.warn(
'[Redis] Starting without cache. '
+ 'API will use MongoDB fallback.'
);
}
const server =
app.listen(port, () => {
console.log(
`API listening on port ${port}`
);
});
async function shutdown(signal) {
console.log(
`${signal} received. Shutting down...`
);
server.close(async () => {
try {
await closeRedis();
await mongoose.connection.close();
console.log(
'Shutdown complete.'
);
process.exit(0);
} catch (error) {
console.error(
'Shutdown error:',
error
);
process.exit(1);
}
});
}
process.on(
'SIGINT',
() => shutdown('SIGINT')
);
process.on(
'SIGTERM',
() => shutdown('SIGTERM')
);
}
startServer().catch((error) => {
console.error(
'Startup error:',
error
);
process.exit(1);
});
MongoDB is treated as required because it is the system of record in this example. Redis is treated as optional because it is an optimization layer.
How the Complete Cache Flow Works
First Request
GET /api/products
↓
Redis GET products:list
↓
MISS
↓
MongoDB query
↓
Redis SET products:list
↓
Return products
Second Request
GET /api/products
↓
Redis GET products:list
↓
HIT
↓
Return cached products
After a Product Update
PUT /api/products/123
↓
MongoDB UPDATE
↓
DEL products:id:123
↓
DEL products:list
↓
Return updated product
The next GET request will miss the cache and rebuild the affected entry.
Redis TTL: How Long Should Cache Data Live?
There is no universal Redis TTL that works for every application.
The correct value depends on:
- how often the source data changes;
- how harmful stale data would be;
- how expensive the original operation is;
- how frequently the endpoint is requested;
- whether writes explicitly invalidate affected keys.
| Data Type | Possible Starting TTL | Typical Consideration |
|---|---|---|
| Rapidly changing metrics | Seconds to minutes | Freshness is important |
| Product catalog | Minutes | Frequently read, usually less volatile |
| Public configuration | Minutes to hours | Often changes infrequently |
| News or content listings | Minutes to tens of minutes | Depends on publishing frequency |
| Static reference data | Hours or longer | Changes relatively rarely |
These values are only starting points. A production TTL should be based on actual freshness requirements and measured application behavior.
Cache Invalidation
TTL prevents an entry from remaining indefinitely, but TTL alone does not guarantee immediate freshness.
Suppose a product list has a five-minute TTL:
GET /api/products
↓
Redis returns cached list
↓
Product price changes
↓
Old value may remain
until TTL expires
If business requirements demand faster freshness, invalidate the affected entries immediately after a successful database mutation.
POST
const product = await Product.create(payload);
await deleteCache('products:list');
PUT
await Product.findByIdAndUpdate(
id,
update,
{
new: true,
runValidators: true
}
);
await deleteCache(
`products:id:${id}`
);
await deleteCache(
'products:list'
);
DELETE
await Product.findByIdAndDelete(id);
await deleteCache(
`products:id:${id}`
);
await deleteCache(
'products:list'
);
Important Concurrency Limitation
Invalidation is not the same as absolute strong consistency.
Consider two concurrent requests:
Request A → reads old MongoDB value
Request B → updates MongoDB
Request B → deletes Redis key
Request A → writes old value back to Redis
The result can be an older value being reintroduced after invalidation.
For many conventional APIs this race is acceptable within the application's consistency requirements. Systems that require stronger guarantees may use versioned keys, request coalescing, locking, write-through approaches, transactional designs, or other consistency mechanisms.
Cache Key Design
Cache keys are part of the application's data architecture. Poor key design can cause collisions, incorrect responses, stale data, and difficult invalidation.
Good examples include:
products:list
products:id:123
user:profile:123
posts:page:1
posts:category:technology:page:2
Use Namespaces
Prefixes make related cache entries easier to understand:
products:list
products:id:123
products:id:456
users:profile:123
users:profile:456
Include Every Response-Changing Dimension
If an endpoint changes its response based on pagination, filtering, sorting, locale, tenant, user identity, or another input, the relevant dimension needs to be represented in the cache key.
For example:
products:list:page:1
products:list:page:2
products:list:category:solar:page:1
products:list:category:solar:page:2
Normalize Parameters
Using an arbitrary URL string as a cache key can produce different keys for requests that are logically equivalent.
For example, these may represent the same logical request:
?page=1&category=solar
?category=solar&page=1
Instead of using the raw URL as the canonical cache identity, normalize the relevant parameters:
const page =
Number(req.query.page) || 1;
const category =
String(req.query.category || 'all');
const cacheKey =
`products:list:` +
`category:${category}:` +
`page:${page}`;
Explicit key construction is often easier to reason about than blindly caching arbitrary URLs.
Consider Versioned Keys
When the response structure changes significantly, versioned cache keys can simplify migrations:
v1:products:list
v2:products:list
A version prefix can allow a new application release to use a new representation without depending on old cached data.
JSON Serialization in Redis
A common approach for REST API responses is to serialize JavaScript objects with JSON.
const product = {
id: 123,
name: 'Laptop',
price: 899
};
await redisClient.set(
'product:123',
JSON.stringify(product),
{
EX: 300
}
);
const cached =
await redisClient.get('product:123');
const result =
cached
? JSON.parse(cached)
: null;
JSON is convenient, but it has limitations.
- Dates: JavaScript
Datevalues become strings. - Undefined: JSON does not preserve JavaScript
undefinedsemantics in the same way as an in-memory object. - Binary data: JSON is usually not the right format for binary content.
- Large objects: serialization and deserialization consume CPU and memory.
- Network overhead: large serialized values still have to cross the network between the application and Redis.
For specialized workloads, Redis data structures such as hashes, sets, sorted sets, and streams may be more appropriate than storing every object as one large JSON string.
Redis Failures and Graceful Fallback
A production cache should assume Redis can become temporarily unavailable.
Possible problems include:
- connection refusal;
- network interruption;
- service restart;
- authentication failure;
- DNS problems;
- memory pressure;
- service maintenance;
- application-to-Redis latency spikes.
When Redis is only an optimization layer, a common policy is:
Try Redis
↓
Success → use cache
↓
Failure → log/measure error
↓
Query MongoDB
↓
Return response
This is often called a fail-open cache strategy: the application continues without the optimization instead of turning a cache failure into an API outage.
However, not every application should fail open. If Redis stores mandatory session state, distributed coordination data, or another critical dependency, the appropriate failure policy may be stricter.
Redis Reconnection and Timeouts
Connection Timeout
A connection timeout limits how long the client should wait while establishing a Redis connection:
const redisClient = createClient({
url: process.env.REDIS_URL,
socket: {
connectTimeout: 5000
}
});
Automatic Reconnection
Node-redis can attempt to reconnect after an unexpected connection loss. Its documented default strategy includes exponential backoff with jitter, and the reconnection strategy can be customized.
Automatic reconnection is useful, but it should not be treated as a substitute for monitoring. A reconnecting client can still produce cache misses, increased request latency, and database load.
Command-Level Timeouts
Some latency-sensitive systems also need a maximum time for an individual Redis operation.
For example:
import {
createClient,
commandOptions
} from 'redis';
const client = createClient({
url: process.env.REDIS_URL
});
await client.connect();
const controller =
new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 1000);
try {
const cached =
await client.get(
commandOptions({
signal: controller.signal
}),
'products:list'
);
} finally {
clearTimeout(timeout);
}
This is an advanced technique. You do not necessarily need a custom command timeout on every application, but latency-sensitive systems should deliberately decide how much Redis latency they are willing to tolerate.
Cache Stampede and the Thundering Herd Problem
A cache stampede occurs when a popular cache entry expires or disappears and many requests attempt to regenerate the same value at the same time.
Popular key expires
↓
Request 1 ─┐
Request 2 │
Request 3 ├──→ MongoDB
Request 4 │
Request 5 ─┘
Instead of reducing database load, the cache can temporarily create a large surge.
![]() |
| Figure 2. Redis Cache Stampede: How an Expired Hot Key Can Overload the Database |
TTL Jitter
Rather than assigning exactly the same expiration time to every cache entry, a small random variation can reduce synchronized expiration:
base TTL = 300 seconds
actual TTL =
300 + random jitter
This is especially useful when large numbers of related keys are created around the same time.
Request Coalescing
Request coalescing allows one request to regenerate a missing value while other requests wait for the same result instead of independently querying MongoDB.
This can be particularly valuable for hot keys.
Distributed Locking
A short-lived distributed lock can coordinate regeneration across multiple application instances. Locks require careful expiration and failure handling, so they should not be added merely because they are available.
Stale-While-Revalidate
Some applications can serve a slightly stale value while one process refreshes the cache in the background.
Cache Warming
Important cache entries can sometimes be populated before predictable traffic peaks or immediately after deployment.
Important Note About the Example
The implementation in this tutorial demonstrates a straightforward cache-aside design. It does not implement distributed locking or request coalescing.
For extremely hot endpoints or very high concurrency, consider the techniques above after measuring the actual workload.
Cache Penetration and Negative Caching
Cache Penetration
Cache penetration can happen when clients repeatedly request resources that do not exist. Because the resource cannot be stored as an ordinary successful result, every request may reach MongoDB.
For example:
GET /api/products/does-not-exist
Potential defenses include:
- request validation;
- rate limiting;
- short-lived negative caching;
- abuse detection;
- appropriate authentication and authorization controls.
Negative Caching
A negative cache entry represents a known absence:
product:not-found:123
A short TTL can prevent repeated database work for a nonexistent resource.
However, negative caching must be invalidated appropriately if the resource can later be created.
Caching Authenticated Data Safely
Authenticated endpoints require much more careful cache-key design.
Consider:
GET /api/profile
If the response depends on the authenticated user, a global key such as:
profile
can cause one user's data to be returned to another user.
A user-specific key may look like:
user:profile:123
The identity used in the key must come from trusted authentication state rather than arbitrary client input.
Authorization Context Matters
User identity is not always sufficient.
The response may depend on:
- roles;
- permissions;
- tenant membership;
- account status;
- feature flags;
- subscription level;
- regional rules.
If those values change the response, they may need to be reflected in the cache identity or associated invalidation strategy.
Should Authenticated Responses Be Cached?
Not necessarily.
For some user-specific endpoints, a direct database query may be safer and simple enough that caching provides little meaningful benefit.
Do not cache authenticated data just because Redis makes it possible.
Redis Cache vs HTTP Cache and CDN Cache
Redis is only one layer in a broader caching architecture.
| Cache Layer | Location | Main Purpose |
|---|---|---|
| Browser cache | User device | Reduce repeated network requests |
| HTTP shared cache | Proxy/CDN infrastructure | Reuse cacheable HTTP responses |
| CDN cache | Edge locations | Serve content closer to users |
| Redis | Server-side infrastructure | Accelerate backend data access |
| MongoDB | Primary data layer | Store application data |
HTTP caching uses mechanisms such as Cache-Control, ETag, and Last-Modified. These solve different problems from an internal Redis cache.
Redis should therefore not be viewed as a replacement for browser caching, HTTP caching, or a CDN.
Redis vs MongoDB
| Characteristic | Redis | MongoDB |
|---|---|---|
| Primary model | In-memory data structures and key-value access | Document database |
| Typical cache role | Excellent | Usually the primary database |
| Latency profile | Very low for appropriate in-memory operations | Depends on query, indexes, workload, and deployment |
| Data role in this tutorial | Temporary cached copy | Source of truth |
| Query model | Key/data-structure oriented | Document queries and aggregations |
| Common use cases | Cache, counters, sessions, rate limits, queues | Primary application data |
The important distinction is architectural rather than simply "which database is faster."
Cache-Aside vs Write-Through vs Write-Behind
| Pattern | Read Behavior | Write Behavior | Complexity | Typical Use |
|---|---|---|---|---|
| Cache-aside | Application checks cache, then database on miss | Application updates database and invalidates or refreshes cache | Low to medium | General APIs |
| Write-through | Reads can come from cache | Cache and underlying source are updated together | Medium | Workloads requiring tighter cache freshness |
| Write-behind | Reads come from cache | Cache accepts writes and persistence happens later | High | Specialized high-write systems |
Cache-aside is a practical starting point for many Node.js APIs because it keeps the database as the source of truth and does not require every database operation to depend on Redis.
What Should Not Be Cached?
| Data | Usually Cache? | Reason |
|---|---|---|
| Public product list | Often yes | Frequently requested and broadly reusable |
| Public configuration | Often yes | Usually changes infrequently |
| User profile | Sometimes | Requires strict user isolation |
| Payment result | Usually avoid | Freshness and correctness are important |
| Password-reset information | Usually avoid | Security-sensitive and short-lived |
| Highly volatile state | Often avoid | Cache may be stale almost immediately |
| Authorization decisions | Extreme care | Permission changes can create security problems |
Do not judge cacheability only by whether the data can physically fit into Redis. Consider security, correctness, freshness, memory usage, and business impact.
Redis Memory Management and Eviction
Redis memory is finite. A production cache should have an intentional memory strategy rather than allowing cache usage to grow without consideration.
Redis supports configurable memory limits and eviction policies. Depending on the deployment and workload, policies can use strategies such as LRU or LFU-style eviction.
A production cache should consider:
- maximum Redis memory;
- average cached object size;
- number of cached keys;
- TTL distribution;
- eviction policy;
- memory fragmentation and operational overhead;
- reserved memory for non-cache operations where applicable.
For a cache-only Redis instance, automatic eviction can be an acceptable part of the design because an evicted value can usually be rebuilt from the source of truth.
Do not make that assumption for data that Redis treats as authoritative or irreplaceable.
Redis Security Considerations
Do Not Expose Redis Unnecessarily
Redis should normally be protected by appropriate network controls. Avoid exposing a production Redis service directly to the public internet unless there is a specific, carefully secured reason.
Use Authentication and Access Controls
Use the authentication and access-control mechanisms supported by your Redis deployment. The application should receive only the permissions it actually needs when your infrastructure supports granular controls.
Use TLS When Required
If Redis traffic crosses a network where transport confidentiality is required, use an encrypted Redis connection supported by the deployment.
Do Not Cache Sensitive Data Automatically
Before caching access tokens, financial information, private documents, credentials, or other sensitive content, determine whether the data should be cached at all.
Respect Trust Boundaries
Do not blindly incorporate arbitrary user-controlled headers, query parameters, or other untrusted values into cache identity.
Shared caches must be designed carefully because incorrect cache identity can contribute to cache-poisoning or data-isolation problems.
How to Measure Redis Cache Performance
Never assume a cache improves an endpoint merely because Redis is fast.
An end-to-end cache hit may still involve:
- network communication;
- Redis command processing;
- serialization or deserialization;
- application logic;
- logging and observability overhead.
Measure the complete request path.
Important Metrics
- p50 latency;
- p95 latency;
- p99 latency;
- cache hit ratio;
- cache miss count;
- database query rate;
- database CPU utilization;
- database connection utilization;
- Redis command latency;
- Redis error rate;
- Redis memory consumption;
- cache fallback count.
Cache Hit Ratio
A basic cache hit ratio can be calculated as:
Cache Hit Ratio =
Cache Hits /
(Cache Hits + Cache Misses)
For example:
9,000 hits
1,000 misses
9,000 / (9,000 + 1,000)
= 0.90
= 90%
A 90% hit ratio is only an illustration, not a universal target.
A 99% hit ratio on an inexpensive database query might save little, while a 70% hit ratio on a highly expensive aggregation might save substantial resources.
Benchmark Before and After
Compare the endpoint before Redis and after Redis:
WITHOUT CACHE
Client
↓
Express
↓
MongoDB
↓
Response
WITH CACHE
Client
↓
Express
↓
Redis
↓
Response
Measure latency and database load under realistic concurrency rather than relying on a single manual browser request.
Cache Observability
A production cache should be observable.
Useful signals include:
X-Cache: HIT
X-Cache: MISS
X-Cache: BYPASS
These response headers are useful during development and testing, but you may choose not to expose internal cache information to public clients in a production environment.
In addition, applications should record metrics for:
- cache hits;
- cache misses;
- cache read failures;
- cache write failures;
- cache delete failures;
- Redis latency;
- Redis connection state;
- database fallback events.
Logging the cache key can help debugging, but avoid logging sensitive cached content.
Production Best Practices
- Reuse Redis connections: create the Redis client once per application process.
- Use environment variables: keep connection URLs and credentials out of source code.
- Assign TTLs: avoid accidental indefinite retention.
- Design deterministic keys: make cache identity predictable.
- Include response-changing dimensions: page, filters, sort order, locale, tenant, and identity may matter.
- Invalidate after writes: remove affected cache entries after successful mutations.
- Handle Redis failures: decide explicitly whether Redis is optional or mandatory.
- Measure performance: track latency, hit rate, database load, and Redis health.
- Control memory: define appropriate memory limits and eviction behavior.
- Avoid huge values: large cache objects increase RAM and serialization costs.
- Protect Redis: use network controls, authentication, ACLs, and TLS when required.
- Separate user data: never use global keys for personalized responses.
- Consider hot keys: high-traffic keys may require stampede mitigation.
- Load test: test realistic concurrency and failure conditions.
- Keep cache logic understandable: avoid hiding important behavior in overly clever abstractions.
Advanced Cache Architecture
A more mature API might eventually contain several cache layers:
┌────────────────────┐
│ Client │
└─────────┬──────────┘
│
▼
┌────────────────────┐
│ Browser / CDN / │
│ HTTP caching │
└─────────┬──────────┘
│
▼
┌────────────────────┐
│ Express API │
└─────────┬──────────┘
│
▼
┌────────────────────┐
│ Redis │
└─────────┬──────────┘
│
Cache Miss
│
▼
┌────────────────────┐
│ MongoDB │
│ Source of Truth │
└────────────────────┘
This architecture is powerful because each layer has a different purpose:
| Layer | Primary Responsibility |
|---|---|
| Browser | Reuse previously downloaded resources |
| CDN/HTTP cache | Reuse cacheable HTTP responses closer to clients |
| Express | Application logic and authorization |
| Redis | Fast backend cache and shared temporary state |
| MongoDB | Persistent application data |
Testing the API
Start the Application
Make sure MongoDB and Redis are running, then execute:
npm start
Test the Health Endpoint
curl http://localhost:3000/health
You should receive a response similar to:
{
"status": "ok",
"redis": "ready",
"mongodb": "connected"
}
Test the Product List
First request:
curl -i http://localhost:3000/api/products
Normally:
X-Cache: MISS
The request queries MongoDB and stores the result in Redis.
Send the request again:
curl -i http://localhost:3000/api/products
Normally:
X-Cache: HIT
The application can now serve the response from Redis without repeating the MongoDB query.
Test an Individual Product
curl -i \
http://localhost:3000/api/products/PRODUCT_ID
The first request should normally produce a miss and populate:
products:id:PRODUCT_ID
Repeating the request should normally produce a cache hit.
Create a Product
curl -i \
-X POST \
http://localhost:3000/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Laptop","price":899,"active":true}'
The route creates the database record and invalidates:
products:list
Update a Product
curl -i \
-X PUT \
http://localhost:3000/api/products/PRODUCT_ID \
-H "Content-Type: application/json" \
-d '{"price":949}'
The update invalidates both:
products:id:PRODUCT_ID
products:list
Delete a Product
curl -i \
-X DELETE \
http://localhost:3000/api/products/PRODUCT_ID
The route deletes the MongoDB document and removes the related cache entries.
Test With Postman
In Postman, create:
GET http://localhost:3000/api/products
Send it twice and inspect the X-Cache response header.
Then perform a POST, PUT, or DELETE operation and repeat the GET request to confirm that invalidation causes the next request to rebuild the cache.
Test Redis Failure
A production-oriented cache should be tested under failure, not only under ideal conditions.
Stop Redis while the application is running and request:
GET /api/products
If Redis is optional and the fallback path is working correctly, the API should continue querying MongoDB instead of automatically returning a Redis error to the client.
The response may show:
X-Cache: BYPASS
The important lesson is that cache failure should not silently become database failure when Redis is only an optimization layer.
Troubleshooting Redis Caching in Node.js
Redis Connection Refused
Verify that Redis is running:
redis-cli -h 127.0.0.1 -p 6379 ping
Check the hostname, port, container networking, firewall, and service status.
ECONNREFUSED
This generally means that the application cannot establish the expected TCP connection to the configured Redis endpoint.
Check:
- hostname;
- port;
- network path;
- firewall rules;
- container configuration;
- Redis service status.
Authentication Failure
Verify the username, password, ACL configuration, and connection URL.
Managed Redis services may require credentials or TLS configuration that a local Redis installation does not.
Cache Always Misses
Log the generated key:
console.log('[Cache] Key:', key);
Then verify that:
- the same request generates the same logical key;
- TTL is not too short;
- the cache is not being deleted unexpectedly;
- the application is connected to the intended Redis instance.
Stale Data
Review both TTL and invalidation.
Ask:
- Which write operations modify this data?
- Which cached resources depend on it?
- Are all mutation paths invalidating those resources?
- Could concurrent requests reintroduce an older value?
JSON Parsing Errors
A malformed or incompatible cache value can cause JSON.parse() to fail.
The example middleware treats invalid JSON as an invalid cache entry and deletes it.
For production logging, avoid dumping sensitive cached content into application logs.
Redis Memory Problems
Inspect:
- cache object size;
- number of keys;
- TTL distribution;
- configured memory limit;
- eviction behavior;
- large or unnecessary objects.
Redis documents configurable memory limits and eviction policies for cache workloads.
Application Works Locally but Not in Production
Check:
- production environment variables;
- Redis hostname;
- port;
- TLS requirements;
- private networking;
- DNS;
- firewall rules;
- authentication;
- container or orchestration configuration.
A local URL such as:
redis://127.0.0.1:6379
usually refers to the local machine. It is normally not the correct connection string for a separate production Redis service.
Common Redis Caching Mistakes
1. Caching Everything
Not every endpoint benefits from caching. Start with frequently requested and relatively expensive operations.
2. No TTL
Permanent entries can consume memory indefinitely and make freshness harder to control.
3. Poor Cache-Key Design
If pagination, filters, locale, tenant, or user identity changes the response, those dimensions need appropriate cache identity.
4. Forgetting Invalidation
A five-minute TTL does not satisfy a requirement that updated data must become visible immediately.
5. Sharing User Data
Never use a global cache key for a response that varies by authenticated user or authorization context.
6. Creating Redis Clients Per Request
Redis clients should generally be created and reused rather than connected independently for every request.
7. Ignoring Redis Errors
Cache errors should remain visible through appropriate logging and metrics even when the API is able to fall back to MongoDB.
8. Caching Huge Responses
Large objects increase memory usage and serialization overhead.
9. Assuming Cached Data Is Always Fresh
Every cache has a freshness policy. Define it explicitly.
10. Never Measuring Effectiveness
A cache with a poor hit rate can add infrastructure and complexity while producing little value.
11. Treating Redis as the Primary Database Without a Clear Requirement
Using Redis as a cache is different from using Redis as an authoritative business-data store.
12. Ignoring Hot Keys
A cache may perform extremely well under normal conditions and still overload MongoDB when a popular key expires under heavy traffic.
Production Redis Caching Checklist
- Redis connection settings come from environment configuration.
- The application reuses its Redis client.
- Every cached resource has a defined freshness policy.
- Cache keys are deterministic and collision-resistant.
- Response-changing parameters are represented in cache identity.
- Write operations invalidate affected entries.
- User-specific data is isolated appropriately.
- Redis failures have an explicit policy.
- Redis latency is bounded appropriately for the application's requirements.
- Redis is protected by suitable network controls.
- Authentication and access controls are configured.
- TLS is enabled when required.
- Memory limits and eviction behavior are understood.
- Cache hit and miss metrics are available.
- Redis errors and fallback events are monitored.
- Large and sensitive values are not cached unnecessarily.
- Hot keys and stampede behavior have been considered.
- Realistic load tests have been performed.
Complete Project Architecture
redis-express-cache/
├── src/
│ ├── config/
│ │ ├── db.js
│ │ └── redis.js
│ ├── middleware/
│ │ └── cache.js
│ ├── models/
│ │ └── Product.js
│ └── routes/
│ └── productRoutes.js
├── .env
├── .gitignore
├── package.json
└── server.js
The final request lifecycle is:
GET request
↓
Build cache key
↓
Redis GET
↓
┌───────────────┐
│ HIT? │
└──────┬────────┘
│
┌───┴────┐
│ │
YES NO
│ │
▼ ▼
Return MongoDB
cached query
value │
▼
Redis SET
+ TTL
│
▼
Response
For mutations:
POST / PUT / DELETE
↓
MongoDB mutation
↓
Invalidate affected keys
↓
Response
This is a solid starting architecture for a conventional read-heavy Express API. Higher-scale systems may eventually require request coalescing, hot-key management, versioned keys, distributed locks, stale-while-revalidate behavior, or more sophisticated consistency mechanisms.
When Should You Avoid Redis Caching?
Redis caching is not a mandatory component of every Node.js application.
You may not need Redis when:
- your API traffic is low;
- database queries are already fast;
- the data changes extremely frequently;
- responses are mostly unique to individual users;
- a CDN or browser cache already solves the bottleneck;
- the additional infrastructure would add more complexity than value.
The right architectural question is not:
"Can I cache this?"
It is:
"Does caching this produce enough measurable benefit
to justify its memory, complexity, consistency,
security, and operational cost?"
Frequently Asked Questions
What is Redis caching in Node.js?
Redis caching in Node.js means storing frequently requested data in Redis so the application can retrieve it without repeating the original database query or expensive computation.
How do I use Redis with Express.js?
Install the redis package, create a reusable Redis client, connect it during application startup, and use the client from route handlers, services, or reusable cache middleware.
Is Redis faster than MongoDB?
Redis is designed for very low-latency in-memory operations, while MongoDB provides a broader document-database query model and persistent data storage. The real performance difference depends on the query, indexes, workload, network path, and deployment.
How long should Redis cache data?
There is no universal TTL. Choose it according to source-data volatility, regeneration cost, and the maximum amount of staleness the application can tolerate.
How do I invalidate Redis cache in Node.js?
After successfully changing the underlying data, remove the affected key with redisClient.del(key) or an equivalent helper. More complex applications may refresh or version the cache instead.
Can Redis replace MongoDB?
Redis and MongoDB can play different architectural roles. In a cache-aside architecture, MongoDB remains the source of truth while Redis stores temporary copies for faster access.
Is Redis suitable for production Node.js applications?
Yes. Redis is widely used in production systems. Production reliability depends on proper network security, memory management, monitoring, timeout policies, failure handling, key design, and application architecture.
How do I cache API responses in Express?
A common approach is to use middleware to check Redis for a key and return the cached value on a hit. On a miss, the route queries the database and explicitly stores the successful result in Redis with a TTL.
What happens if Redis goes down?
If Redis is only a cache, the application can often fall back to MongoDB and continue serving requests, although database load and latency may increase. If Redis provides mandatory functionality, a stricter failure policy may be necessary.
Should I cache authenticated API responses?
Only when the benefits justify the additional complexity and the cache identity safely isolates the response according to user identity and authorization context.
What is the cache-aside pattern?
The application first checks the cache. On a hit, it returns the cached result. On a miss, it loads the data from the primary source, stores the result in the cache, and returns it to the client.
How can I prevent a Redis cache stampede?
Common approaches include TTL jitter, request coalescing, short-lived distributed locks, stale-while-revalidate designs, and cache warming.
Should every API response be cached?
No. Cache operations where the performance benefit justifies the additional memory, invalidation, consistency, security, and operational complexity.
What is a good Redis cache TTL?
A good TTL depends on the application's freshness requirements. Rapidly changing data may need seconds or minutes, while relatively static data can often tolerate much longer expiration periods.
What is a cache hit ratio?
The cache hit ratio is the percentage of cacheable requests that are served successfully from the cache instead of requiring the original data source:
Hit Ratio =
Hits / (Hits + Misses)
Can Redis cache MongoDB queries?
Yes. The application can cache the result of a MongoDB query in Redis. Redis does not automatically understand the business meaning of a MongoDB query; the application decides how the query result is represented and keyed.
What is cache invalidation?
Cache invalidation is the process of removing or replacing cached data when the underlying source changes so that future requests do not continue using an outdated value.
What is negative caching?
Negative caching temporarily stores the fact that a requested resource does not exist. It can reduce repeated database lookups for nonexistent resources, but it should use a short TTL and appropriate invalidation.
What is a hot key in Redis?
A hot key is a cache key requested unusually often. Hot keys can become a bottleneck or create a large database surge if they expire simultaneously, so high-traffic systems may need special handling.
Conclusion
Redis caching can significantly reduce repeated database work in Node.js and Express applications, but production caching is about much more than calling get() and set().
The cache-aside pattern is a practical starting point: check Redis first, query MongoDB on a miss, store the result with a TTL, and return the data. Production quality then depends on the surrounding architecture.
A reliable implementation should use deliberate cache keys, sensible TTLs, explicit invalidation, safe handling of authenticated data, Redis failure fallbacks, memory controls, monitoring, and protection against cache stampedes.
The best way to introduce Redis caching is to start small. Choose one endpoint that is both frequently requested and expensive enough to matter. Measure its current latency and database load, introduce a cache, verify correctness and invalidation, then measure again.
If the measurements show a meaningful improvement, expand the strategy to other high-value endpoints. If they do not, remove the unnecessary complexity.
Redis should be treated as an optimization layer where appropriate—not as a requirement simply because an application uses Node.js or Express.
Code examples are intentionally focused on clarity and production-oriented fundamentals. Real applications should adapt validation, authentication, authorization, observability, timeout policies, consistency guarantees, and infrastructure configuration to their specific workload and threat model.



Join the conversation