Caching is the single highest-leverage performance technique available to a MEAN Stack application. A MongoDB query that takes 50ms — even with perfect indexes — runs in under 1ms when its result is cached in Redis. Caching sits at the intersection of performance and correctness: cache too aggressively and users see stale data; cache too conservatively and you get no benefit. This lesson builds a complete, production-ready Redis caching layer for the Express API — with correct cache invalidation, stampede prevention, multi-level caching, and patterns for caching database queries, computed results, and API responses.
Cache Strategy Reference
| Strategy | On Cache Hit | On Cache Miss | Best For |
|---|---|---|---|
| Cache-aside (lazy) | Return cached value | Read from DB, store in cache, return | Most use cases — app controls cache population |
| Write-through | Return cached value | Read from DB | Always write to cache and DB simultaneously |
| Write-behind | Return cached value | Read from DB | Write to cache immediately, DB asynchronously |
| Read-through | Return cached value | Cache fetches from DB itself | Transparent caching layer |
| Refresh-ahead | Return cached value, refresh in background | Block on DB read | High-traffic data with predictable access patterns |
Redis Data Types for Caching
| Type | Command | Use For |
|---|---|---|
| String | SET key value EX seconds |
Serialised JSON objects, counts, flags |
| Hash | HSET key field value |
Structured objects — update individual fields without re-serialising |
| Set | SADD key member |
Tag-based invalidation — track which keys belong to a group |
| Sorted Set | ZADD key score member |
Leaderboards, rate limiting with sliding windows |
| List | LPUSH / RPUSH key value |
Queues, activity feeds |
SET key value NX EX seconds Redis command atomically sets a key only if it does not exist — perfect for distributed locks.tasks:all that returns all tasks would return User A’s tasks to User B. Always namespace cache keys with the user ID or session token: tasks:userId:${userId}:page:1. Also be careful about caching authentication tokens, permission checks, or any data whose staleness could be a security issue — a cached “admin” role check that does not expire immediately after role revocation is a privilege escalation vulnerability.Complete Redis Caching Implementation
// src/config/redis.js — Redis client setup
const { createClient } = require('redis');
let client;
async function getRedisClient() {
if (client?.isReady) return client;
client = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
socket: {
reconnectStrategy: retries => Math.min(retries * 50, 2000),
connectTimeout: 5000,
},
});
client.on('error', err => console.error('Redis error:', err.message));
client.on('connect', () => console.log('Redis connected'));
client.on('reconnecting', () => console.warn('Redis reconnecting...'));
await client.connect();
return client;
}
module.exports = { getRedisClient };
// ── src/services/cache.service.js — typed cache wrapper ──────────────────
const { getRedisClient } = require('../config/redis');
const DEFAULT_TTL = 300; // 5 minutes
class CacheService {
async get(key) {
const redis = await getRedisClient();
const data = await redis.get(key);
return data ? JSON.parse(data) : null;
}
async set(key, value, ttlSeconds = DEFAULT_TTL) {
const redis = await getRedisClient();
await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
}
async del(...keys) {
const redis = await getRedisClient();
if (keys.length > 0) await redis.del(keys);
}
// Atomic get-or-set with stampede prevention
async getOrSet(key, fetchFn, ttlSeconds = DEFAULT_TTL) {
const redis = await getRedisClient();
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// Stampede prevention: set a lock
const lockKey = `lock:${key}`;
const locked = await redis.set(lockKey, '1', { NX: true, EX: 10 });
if (!locked) {
// Another process is fetching — poll briefly
await new Promise(r => setTimeout(r, 100));
return this.getOrSet(key, fetchFn, ttlSeconds); // retry
}
try {
const value = await fetchFn();
await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
return value;
} finally {
await redis.del(lockKey);
}
}
// Tag-based invalidation — group keys by tag
async setWithTags(key, value, tags, ttlSeconds = DEFAULT_TTL) {
const redis = await getRedisClient();
const multi = redis.multi();
multi.set(key, JSON.stringify(value), { EX: ttlSeconds });
tags.forEach(tag => multi.sAdd(`tag:${tag}`, key));
await multi.exec();
}
async invalidateTag(tag) {
const redis = await getRedisClient();
const keys = await redis.sMembers(`tag:${tag}`);
if (keys.length > 0) {
await redis.del([...keys, `tag:${tag}`]);
}
}
}
const cache = new CacheService();
module.exports = cache;
// ── Task service with caching ─────────────────────────────────────────────
const cache = require('./cache.service');
const Task = require('../models/task.model');
const TASK_TTL = 60; // 1 minute for individual tasks
const LIST_TTL = 30; // 30 seconds for lists (changes more frequently)
function taskKey(id) { return `task:${id}`; }
function taskListKey(userId, p) { return `tasks:${userId}:page:${p}`; }
function userTagKey(userId) { return `user:${userId}`; }
exports.getById = async (id) => {
return cache.getOrSet(
taskKey(id),
() => Task.findById(id).lean(),
TASK_TTL
);
};
exports.getAll = async (userId, page = 1) => {
return cache.getOrSet(
taskListKey(userId, page),
() => Task.find({ user: userId }).skip((page-1)*10).limit(10).lean(),
LIST_TTL
);
};
exports.create = async (dto, userId) => {
const task = await Task.create({ ...dto, user: userId });
// Invalidate all list pages for this user — we don't know which page it belongs to
await cache.invalidateTag(userTagKey(userId));
return task;
};
exports.update = async (id, dto) => {
const task = await Task.findByIdAndUpdate(id, dto, { new: true }).lean();
if (task) {
// Update cached value
await cache.set(taskKey(id), task, TASK_TTL);
// Invalidate list pages
await cache.invalidateTag(userTagKey(task.user.toString()));
}
return task;
};
exports.delete = async (id) => {
const task = await Task.findByIdAndDelete(id).lean();
if (task) {
await cache.del(taskKey(id));
await cache.invalidateTag(userTagKey(task.user.toString()));
}
return task;
};
// ── HTTP response caching middleware ──────────────────────────────────────
// Cache-aside for GET endpoints
function cacheMiddleware(ttl = 60) {
return async (req, res, next) => {
if (req.method !== 'GET') return next();
const key = `http:${req.user?.sub || 'anon'}:${req.url}`;
const cached = await cache.get(key);
if (cached) {
res.setHeader('X-Cache', 'HIT');
return res.json(cached);
}
// Intercept res.json to store in cache
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode === 200) {
cache.set(key, body, ttl).catch(() => {}); // fire and forget
}
res.setHeader('X-Cache', 'MISS');
return originalJson(body);
};
next();
};
}
// Usage:
// router.get('/api/v1/tasks', authGuard, cacheMiddleware(30), tasksController.getAll);
How It Works
Step 1 — Cache-Aside Is the Default Pattern
Cache-aside (lazy loading) keeps the application in control: check the cache first, fetch from the database on miss, then store in cache. The cache is populated on demand — only data that is actually requested gets cached. TTL ensures stale data eventually expires. This pattern is the easiest to implement correctly and the most flexible for mixed access patterns.
Step 2 — Stampede Prevention Uses Atomic Redis Operations
SET lockKey 1 NX EX 10 atomically sets the lock key only if it does not already exist (NX), with a 10-second expiry. If another concurrent cache miss also attempts this, they get null (lock already held) and wait. The 10-second expiry ensures the lock is released even if the fetching process crashes. This prevents N concurrent requests from all hitting the database when a popular cache key expires.
Step 3 — Tag-Based Invalidation Groups Related Keys
A user’s tasks may be cached under dozens of keys (tasks:userId:page:1, tasks:userId:page:2, etc.). When a task is created or deleted, all pages must be invalidated. Tag sets track which keys belong to a logical group — SADD tag:user:123 "tasks:123:page:1". Invalidating the tag reads all member keys with SMEMBERS and deletes them in a single pipeline. This is more reliable than trying to enumerate keys with SCAN.
Step 4 — Redis Transactions (MULTI/EXEC) Ensure Atomicity
Caching a value and adding it to a tag set must happen atomically — if the value is set but the tag write fails, the tag-based invalidation will miss this key. Wrapping both in a Redis MULTI/EXEC transaction (via redis.multi().set(...).sAdd(...).exec()) ensures both succeed or both fail. Redis transactions are not like SQL transactions — they queue commands and execute all atomically, but they do not roll back on error.
Step 5 — Response Caching Intercepts res.json
The HTTP caching middleware intercepts res.json() by replacing it with a wrapper that stores the response body in Redis before passing through to the original function. This is a non-invasive way to add response caching — route handlers require no modification. The cache key includes the user ID (from req.user.sub) to ensure per-user caching, and the full request URL (including query string) to cache different pages and filters separately.
Quick Reference
| Task | Code |
|---|---|
| Get cached value | const val = await cache.get(key) |
| Set with TTL | await cache.set(key, value, 60) |
| Get or fetch | await cache.getOrSet(key, () => db.find(), 60) |
| Invalidate key | await cache.del(key) |
| Tag-based invalidation | await cache.setWithTags(key, val, ['user:123']) then cache.invalidateTag('user:123') |
| Atomic lock | redis.set(lockKey, '1', { NX: true, EX: 10 }) |
| Pipeline commands | redis.multi().set(...).sAdd(...).exec() |
| User-scoped key | `tasks:${userId}:page:${page}` |