Redis Caching — Cache-Aside, Invalidation, and Stampede Prevention

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
Note: Cache invalidation is one of the two hard problems in computer science (the other being naming things). The most reliable approach is time-based expiry (TTL) — every cached value expires automatically after a defined period. You never need to explicitly invalidate unless data changes must be reflected immediately. For data that changes on write (a task that gets updated), use write-invalidation: when the task is updated, delete its cache key so the next read fetches fresh data. Never try to update a cached value in place — delete it and let the next read repopulate it.
Tip: Prevent cache stampede (thundering herd on cache miss) using the lock-based approach: when a cache miss occurs, set a short-lived lock key before fetching from the database. Other concurrent requests that also miss the cache see the lock and wait rather than all hitting the database simultaneously. After the database fetch completes, store the result and release the lock. The SET key value NX EX seconds Redis command atomically sets a key only if it does not exist — perfect for distributed locks.
Warning: Never cache sensitive per-user data in a shared cache key. A cache key like 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.

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}`

🧠 Test Yourself

100 concurrent requests arrive at the same moment for a cache key that just expired. Without stampede prevention, what happens and what is the Redis command that provides a distributed lock?