Advanced (Updated: 6/1/2026)

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.

Claude Code Redis Caching Guide: TTL, Invalidation, and Stampede Prevention

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.

DataExample keyTTL guideInvalidationRisk
Public product listclaudecodelab:v1:products:list:en5 minutesDelete list keys after product updatesDo not leave price changes to TTL alone
Article pageclaudecodelab:v1:posts:item:{slug}10 minutesDelete on publish, edit, or unpublishNever cache drafts or previews
Admin daily statsclaudecodelab:v1:analytics:daily:{date}30 secondsUsually TTL onlyDo not use for accounting truth
External API resultclaudecodelab:v1:exchange-rate:usd-jpy1 to 15 minutesManual refresh or TTLCheck provider storage rules
Logged-in user dataDo not cache by default0 secondsNoneKeep 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.

#Claude Code #Redis #caching #Node.js #performance
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.