Claude Code Redis Caching Guide: TTL, Invalidation, and Stampede Prevention
A practical Claude Code guide to Redis caching with key design, TTLs, invalidation, Node.js code, tests, and review checks.
Redis is an in-memory key-value store, meaning it keeps data in RAM and retrieves it by key very quickly. It is a good fit for repeated database aggregates, external API responses, rankings, short-lived sessions, and rate limits. It is also easy to misuse: if you ask Claude Code to “add Redis caching” without policy, you can get stale prices, inventory that does not update, unsafe user data, or a production Redis blocked by a broad key scan.
This guide turns Redis caching into a workflow Claude Code can implement and review. The core decisions are: what may be stale, how the key is built, how long the value lives, when it is invalidated, and how the app avoids a cache stampede when a popular key expires. For a broader cache-layer map, see Claude Code caching strategies for real apps. If Redis is also part of your async workflow, pair this with Claude Code job queues and background processing.
flowchart LR
Request["HTTP request"] --> Cache["Redis cache"]
Cache -->|hit| Response["Fast response"]
Cache -->|miss| Lock["Short lock"]
Lock --> Loader["DB or external API"]
Loader --> Cache
Loader --> Response
Admin["Update event"] --> Invalidate["Delete known keys"]
Invalidate --> Cache
Start With Policy
Put the cache policy in CLAUDE.md or directly in the Claude Code task before any implementation. Code can reveal where data comes from, but it cannot infer the business tolerance for stale data.
| Data | Example key | TTL guide | Invalidation | Risk |
|---|---|---|---|---|
| Public product list | claudecodelab:v1:products:list:en | 5 minutes | Delete list keys after product updates | Do not leave price changes to TTL alone |
| Article page | claudecodelab:v1:posts:item:{slug} | 10 minutes | Delete on publish, edit, or unpublish | Never cache drafts or previews |
| Admin daily stats | claudecodelab:v1:analytics:daily:{date} | 30 seconds | Usually TTL only | Do not use for accounting truth |
| External API result | claudecodelab:v1:exchange-rate:usd-jpy | 1 to 15 minutes | Manual refresh or TTL | Check provider storage rules |
| Logged-in user data | Do not cache by default | 0 seconds | None | Keep shared caches away from personal data |
The practical rule is simple: public, repeatable, regeneratable data can be cached; personal, permission-sensitive, billing, and security decisions usually should not be in Redis unless you have a separate security design.
Claude Code Task Template
Use a task like this when asking Claude Code to add Redis. It keeps the agent from adding a helper without invalidation or tests.
Add a Redis cache-aside layer to this Node.js app.
Requirements:
1. Use the official node-redis package named redis
2. Build keys as claudecodelab:v1:{domain}:{resource}:{id}
3. Choose TTLs from the cache policy and add jitter within 10%
4. After updates, delete known related keys only after the DB write succeeds
5. Do not use KEYS in production code; use known keys, SCAN, or related-key sets
6. Prevent cache stampede for popular keys with a short lock
7. Add node:test coverage for key generation, TTL range, getOrSet, and stampede behavior
Return:
- Changed files
- Tests run
- Data intentionally excluded from cache and why
For review discipline, add the same expectations to your Claude Code code review checklist.
Runnable Node.js Implementation
The official Redis Node.js client is the redis package. The current connection pattern is documented in the node-redis guide.
mkdir redis-cache-demo
cd redis-cache-demo
npm init -y
npm install redis
docker run --name redis-cache-demo -p 6379:6379 -d redis:7-alpine
Keep keys and TTL policy in one file so Claude Code has one place to update.
// cache-policy.js
const CACHE_PREFIX = "claudecodelab";
const CACHE_VERSION = "v1";
const CACHE_POLICY = {
productList: { ttl: 300, jitter: 30 },
productItem: { ttl: 600, jitter: 60 },
dailyStats: { ttl: 30, jitter: 5 },
};
function normalizePart(value) {
const part = String(value).trim().toLowerCase();
if (part.length === 0) {
throw new Error("cache key part must not be empty");
}
return encodeURIComponent(part);
}
function cacheKey(parts) {
if (!Array.isArray(parts) || parts.length === 0) {
throw new Error("cacheKey requires a non-empty parts array");
}
return [CACHE_PREFIX, CACHE_VERSION, ...parts.map(normalizePart)].join(":");
}
function ttlWithJitter(baseSeconds, maxJitterSeconds = 30) {
if (!Number.isInteger(baseSeconds) || baseSeconds <= 0) {
throw new Error("base TTL must be a positive integer");
}
const jitter = Math.max(0, Math.floor(maxJitterSeconds));
return baseSeconds + Math.floor(Math.random() * (jitter + 1));
}
module.exports = { CACHE_POLICY, cacheKey, ttlWithJitter };
Create one shared Redis client instead of reconnecting on every request.
// redis-client.js
const { createClient } = require("redis");
const redis = createClient({
url: process.env.REDIS_URL || "redis://localhost:6379",
});
redis.on("error", (error) => {
console.error("Redis Client Error", error);
});
let connecting;
async function getRedis() {
if (redis.isOpen) return redis;
if (!connecting) {
connecting = redis.connect();
}
await connecting;
return redis;
}
async function closeRedis() {
if (redis.isOpen) {
await redis.quit();
}
connecting = undefined;
}
module.exports = { getRedis, closeRedis };
The cache layer uses cache-aside. It reads Redis first, calls the loader only on miss, stores JSON with EX, and uses a short SET NX PX lock so repeated misses do not all hit the database at once.
// redis-cache.js
const { randomUUID } = require("node:crypto");
const UNLOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
`;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
class RedisJsonCache {
constructor(redis, options = {}) {
this.redis = redis;
this.defaultTtl = options.defaultTtl || 300;
this.lockMs = options.lockMs || 5000;
this.waitMs = options.waitMs || 50;
this.waitRetries = options.waitRetries || 10;
}
async get(key) {
const raw = await this.redis.get(key);
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
await this.redis.del(key);
return null;
}
}
async set(key, value, ttlSeconds = this.defaultTtl) {
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
throw new Error("ttlSeconds must be a positive integer");
}
await this.redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
}
async invalidate(keys) {
const list = Array.isArray(keys) ? keys : [keys];
if (list.length === 0) return 0;
return this.redis.del(list);
}
async getOrSet(key, ttlSeconds, loader) {
const cached = await this.get(key);
if (cached !== null) {
return { value: cached, cacheStatus: "hit" };
}
const lockKey = `${key}:lock`;
const token = randomUUID();
const acquired = await this.redis.set(lockKey, token, {
NX: true,
PX: this.lockMs,
});
if (acquired === "OK") {
try {
const fresh = await loader();
await this.set(key, fresh, ttlSeconds);
return { value: fresh, cacheStatus: "miss" };
} finally {
await this.redis.eval(UNLOCK_SCRIPT, {
keys: [lockKey],
arguments: [token],
});
}
}
for (let attempt = 0; attempt < this.waitRetries; attempt += 1) {
await sleep(this.waitMs);
const afterWait = await this.get(key);
if (afterWait !== null) {
return { value: afterWait, cacheStatus: "hit-after-wait" };
}
}
const fallback = await loader();
await this.set(key, fallback, Math.max(5, Math.floor(ttlSeconds / 3)));
return { value: fallback, cacheStatus: "miss-after-timeout" };
}
}
module.exports = { RedisJsonCache };
This demo uses an in-memory “DB” so you can run it as-is. In a real app, replace loadProductsFromDb() with Prisma, Supabase, or your API client. Related implementation guides: Prisma ORM with Claude Code and Supabase integration with Claude Code.
// demo-products.js
const { CACHE_POLICY, cacheKey, ttlWithJitter } = require("./cache-policy");
const { getRedis, closeRedis } = require("./redis-client");
const { RedisJsonCache } = require("./redis-cache");
const db = {
products: [
{ id: "p1", locale: "en", name: "CLAUDE.md Template", price: 9, published: true },
{ id: "p2", locale: "en", name: "Claude Code Training", price: 199, published: true },
{ id: "p3", locale: "ja", name: "Review Prompt Pack", price: 980, published: true },
],
};
async function loadProductsFromDb(locale) {
await new Promise((resolve) => setTimeout(resolve, 80));
return db.products.filter((product) => product.locale === locale && product.published);
}
async function listPublishedProducts(cache, locale) {
const key = cacheKey(["products", "list", locale]);
const ttl = ttlWithJitter(CACHE_POLICY.productList.ttl, CACHE_POLICY.productList.jitter);
return cache.getOrSet(key, ttl, () => loadProductsFromDb(locale));
}
async function main() {
const redis = await getRedis();
const cache = new RedisJsonCache(redis);
const first = await listPublishedProducts(cache, "en");
const second = await listPublishedProducts(cache, "en");
console.log({
firstStatus: first.cacheStatus,
secondStatus: second.cacheStatus,
products: second.value,
});
await cache.invalidate([cacheKey(["products", "list", "en"])]);
await closeRedis();
}
main().catch(async (error) => {
console.error(error);
await closeRedis();
process.exitCode = 1;
});
node demo-products.js
Tests
Caching bugs often hide behind fast responses, so test the policy and the concurrency branch.
// redis-cache.test.js
const test = require("node:test");
const assert = require("node:assert/strict");
const { cacheKey, ttlWithJitter } = require("./cache-policy");
const { RedisJsonCache } = require("./redis-cache");
class FakeRedis {
constructor() {
this.store = new Map();
}
async get(key) {
const entry = this.store.get(key);
if (!entry) return null;
if (entry.expiresAt && entry.expiresAt <= Date.now()) {
this.store.delete(key);
return null;
}
return entry.value;
}
async set(key, value, options = {}) {
if (options.NX && (await this.get(key)) !== null) {
return null;
}
const ttlMs = options.PX || (options.EX ? options.EX * 1000 : 0);
this.store.set(key, {
value,
expiresAt: ttlMs ? Date.now() + ttlMs : 0,
});
return "OK";
}
async del(keys) {
const list = Array.isArray(keys) ? keys : [keys];
let deleted = 0;
for (const key of list) {
if (this.store.delete(key)) deleted += 1;
}
return deleted;
}
async eval(_script, options) {
const [key] = options.keys;
const [token] = options.arguments;
if ((await this.get(key)) === token) {
return this.del(key);
}
return 0;
}
}
test("cacheKey encodes dynamic parts", () => {
assert.equal(
cacheKey(["Products", "List", "en/US"]),
"claudecodelab:v1:products:list:en%2Fus"
);
});
test("ttlWithJitter stays inside the expected range", () => {
for (let i = 0; i < 50; i += 1) {
const ttl = ttlWithJitter(300, 30);
assert.ok(ttl >= 300);
assert.ok(ttl <= 330);
}
});
test("getOrSet caches the first loader result", async () => {
const cache = new RedisJsonCache(new FakeRedis());
let loads = 0;
const first = await cache.getOrSet("products:list", 60, async () => {
loads += 1;
return [{ id: "p1" }];
});
const second = await cache.getOrSet("products:list", 60, async () => {
loads += 1;
return [{ id: "p2" }];
});
assert.equal(first.cacheStatus, "miss");
assert.equal(second.cacheStatus, "hit");
assert.equal(loads, 1);
assert.deepEqual(second.value, [{ id: "p1" }]);
});
test("getOrSet waits instead of running duplicate loaders", async () => {
const cache = new RedisJsonCache(new FakeRedis(), { waitMs: 5, waitRetries: 20 });
let loads = 0;
const loader = async () => {
loads += 1;
await new Promise((resolve) => setTimeout(resolve, 20));
return { total: 42 };
};
const results = await Promise.all([
cache.getOrSet("analytics:daily", 30, loader),
cache.getOrSet("analytics:daily", 30, loader),
]);
assert.equal(loads, 1);
assert.deepEqual(results[0].value, { total: 42 });
assert.deepEqual(results[1].value, { total: 42 });
});
node --test redis-cache.test.js
Practical Use Cases
Public catalogs and article lists are the safest first use case. The same response is shown to many readers, and after an edit you can delete the known list and item keys.
Admin analytics are a second use case. Page views, conversion rates, and revenue previews can often be 30 seconds to 5 minutes old. Permissions, invoices, and security decisions should not use this cache.
External APIs are a third use case. Exchange rates, weather, plan metadata, and public GitHub metadata can be cached briefly to reduce rate-limit pressure. Ask Claude Code to inspect provider terms before storing data.
Rate limits and short-lived sessions are a fourth use case. Redis works well for INCR plus EXPIRE, but authentication data needs a separate security review.
Pitfalls
The most common bug is an incomplete key. products:list does not distinguish locale, currency, tenant, filters, or publication state. Include every condition that changes the result.
Do not delete cache before the database write succeeds. The safe order is DB write, known Redis key deletion, then CDN purge if needed.
Avoid KEYS user:* in production. Use known keys, a Redis Set of related keys, or a maintenance command based on SCAN.
Handle null values intentionally. The implementation above treats null as a miss. If you need negative caching, store an object such as { found: false }.
Finally, do not turn Redis into your system of record. Orders, contracts, and audit logs belong in durable storage. Redis should hold values you can regenerate.
Review Checklist
- Personal, permission, and billing data are excluded or explicitly justified
- Keys include locale, tenant, role, query, and version when those change output
- TTL is tied to a freshness requirement, not a guess
- Updates delete related keys only after the write succeeds
- Production code does not use
KEYS - Stampede prevention uses lock, jitter, stale fallback, or a combination
- Tests cover key generation, TTL range, cache hit, and concurrent miss
- Logs or metrics can show hit rate and fallback behavior
Make this checklist part of Claude Code’s definition of done. For team workflow, combine it with the Claude Code review workflow checklist.
Official References and Next Step
Use the official Redis documentation, node-redis guide, Redis patterns, and Redis optimization guide as source links in Claude Code tasks.
If you want the policy, prompts, and review checklist packaged into a team workflow, start with the ClaudeCodeLab products and templates. For help mapping Redis, CDN, database writes, and observability onto a real codebase, use Claude Code training and consultation.
After trying this article’s code in a small Node.js demo, Masa saw the first list request call the loader, the second request become a Redis hit, and the concurrent-miss test keep the loader to one call. The biggest win was not Redis alone; it was writing down key design, TTL, invalidation, and review rules before asking Claude Code to edit the repository.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Receipt Pattern: Record Scope, Proof, and Rollback
A permission receipt pattern for Claude Code: allowed actions, approval boundaries, proof commands, rollback, and revenue CTA checks.
Safe Agent Harness Design for Claude Code and Codex: Permissions, Checks, and Rollback
Build a practical agent harness for Claude Code and Codex with policy, planning, verification, and recovery layers.
Claude Code Subagents: A Practical Guide to Safe Agent Delegation
Claude Code subagent guide for safe parallel article and code work: delegation rules, prompts, pitfalls, and checks.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.