Caching Strategies with Claude Code: Redis Patterns and Cache Invalidation
Source: Dev.to
Caching is easy to get wrong. Cache too aggressively and you serve stale data. Cache too little and you get no benefit. Claude Code can implement caching patterns correctly — with the right CLAUDE.md configuration.
Caching Rules
What to Cache
- Master data (user roles, config, feature flags): TTL 5 min
- Expensive computations (aggregations, reports): TTL 15 min
- User sessions: TTL = session expiry
- API responses from external services: TTL 1 min
What NOT to Cache
- User‑specific transactional data (orders, payments in flight)
- Real‑time data (stock prices, live notifications)
- Anything that must be consistent immediately after write
Cache Keys
- Format:
{service}:{entity}:{id}:{version}or{service}:{entity}:list:{filter_hash} - Examples:
user:profile:123,product:list:abc123,config:features:v1 - Never use raw user input in cache keys (injection risk)
Invalidation Strategy
- Write‑through – update cache on every write (for consistency)
- TTL‑based – use short TTL when consistency is flexible
- Event‑based – invalidate on domain events (prefer this for critical data)
Redis Configuration
- Client:
src/lib/redis.ts(singleton) - Default serialization: JSON
- Error handling: treat Redis errors as cache misses (never fail the request)
Cache Utility Module (src/lib/cache.ts)
// src/lib/cache.ts
import { createClient } from 'redis';
import logger from './logger';
const client = createClient(); // configure as needed
client.on('error', (err) => logger.error('Redis error', err));
export const cache = {
async get(key: string): Promise {
try {
const raw = await client.get(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch (err) {
logger.error('Cache get error', err);
return null;
}
},
async set(key: string, value: unknown, ttlSeconds: number): Promise {
try {
const serialized = JSON.stringify(value);
await client.set(key, serialized, { EX: ttlSeconds });
} catch (err) {
logger.error('Cache set error', err);
}
},
async del(key: string | string[]): Promise {
try {
if (Array.isArray(key)) await client.del(...key);
else await client.del(key);
} catch (err) {
logger.error('Cache delete error', err);
}
},
async exists(key: string): Promise {
try {
const result = await client.exists(key);
return result === 1;
} catch (err) {
logger.error('Cache exists error', err);
return false;
}
},
};
- Never throw on Redis errors — return
null/falseinstead. - Errors are logged via
logger.tsbut do not crash the application.
Cache‑Aside Pattern for getUserProfile()
// Example implementation
async function getUserProfile(userId: string): Promise {
const cacheKey = `user:profile:${userId}`;
// 1️⃣ Try cache first
const cached = await cache.get(cacheKey);
if (cached) return cached;
// 2️⃣ Fetch from DB
const profile = await db.user.findUnique({ where: { id: userId } });
if (!profile) return null;
// 3️⃣ Store in cache (non‑blocking)
cache.set(cacheKey, profile, 300).catch(logger.error);
return profile;
}
Behaviour
- Cache hit → returns cached data.
- Cache miss → fetches from DB, stores result with TTL 5 min, returns it.
- Redis error → falls back to DB without caching (graceful degradation).
Cache Invalidation Logic (User Profile Update)
- Invalidate
user:profile:{userId}and any list caches that include the user. - Use a domain event (
user.updated) rather than direct cache calls in the service.
// event handler (e.g., src/events/userUpdated.ts)
import { cache } from '../lib/cache';
import logger from '../lib/logger';
export async function handleUserUpdated(event: { userId: string }) {
const { userId } = event;
const profileKey = `user:profile:${userId}`;
// Invalidate the profile cache
await cache.del(profileKey).catch(logger.error);
// Invalidate list caches (wildcard)
const pattern = 'user:list:*';
try {
const stream = client.scanIterator({ MATCH: pattern });
const keys: string[] = [];
for await (const key of stream) {
keys.push(key);
}
if (keys.length) await cache.del(keys);
} catch (err) {
logger.error('Error invalidating user list caches', err);
}
}
The service emitting the event:
// src/services/userService.ts
import { emit } from '../events/bus';
export async function updateUserProfile(userId: string, data: Partial) {
const updated = await db.user.update({ where: { id: userId }, data });
await emit('user.updated', { userId });
return updated;
}
Paginated User List Caching (Cursor‑Based)
- Cache key:
user:list:{cursor}:{limit} - TTL: 2 min (shorter due to mutation risk)
- Invalidation: on any user creation/deletion, bulk‑delete all
user:list:*keys usingSCAN.
// src/lib/userListCache.ts
import { cache } from './cache';
import logger from './logger';
import { client as redisClient } from './redis'; // expose raw client for SCAN
export async function getUserList(cursor: string, limit: number): Promise {
const cacheKey = `user:list:${cursor}:${limit}`;
const cached = await cache.get(cacheKey);
if (cached) return cached;
const users = await db.user.findMany({
take: limit,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { id: 'asc' },
});
// Store result (non‑blocking)
cache.set(cacheKey, users, 120).catch(logger.error);
return users;
}
// Invalidation helper
export async function invalidateUserListCaches() {
const pattern = 'user:list:*';
try {
const stream = redisClient.scanIterator({ MATCH: pattern });
const keys: string[] = [];
for await (const key of stream) {
keys.push(key);
}
if (keys.length) await cache.del(keys);
} catch (err) {
logger.error('Failed to invalidate user list caches', err);
}
}
Trigger invalidateUserListCaches() from domain events such as user.created and user.deleted.
Cache‑Check Hook (.claude/hooks/check_cache.py)
# .claude/hooks/check_cache.py
import json, re, sys
data = json.load(sys.stdin)
content = data.get("tool_input", {}).get("content", "") or ""
fp = data.get("tool_input", {}).get("file_path", "")
# Only check route handlers (not repositories)
if not fp or "routes" not in fp and "controllers" not in fp:
sys.exit(0)
# DB calls without cache check nearby
if re.search(r'prisma\.(user|product|config)\.findMany', content):
if 'cache.get' not in content and 'cache.set' not in content:
print("[CACHE] DB call without caching in route handler", file=sys.stderr)
sys.exit(0)
The script flags direct Prisma findMany calls in route handlers that lack surrounding cache logic.
Tests for getUserProfile
// tests/getUserProfile.test.ts
import { cache } from '../src/lib/cache';
import { db } from '../src/lib/prisma';
import { getUserProfile } from '../src/services/userService';
import logger from '../src/lib/logger';
jest.mock('../src/lib/cache');
jest.mock('../src/lib/prisma');
jest.mock('../src/lib/logger');
describe('getUserProfile', () => {
const userId = '123';
const profile = { id: userId, name: 'Alice' } as UserProfile;
afterEach(() => {
jest.clearAllMocks();
});
it('returns cached data on cache hit', async () => {
(cache.get as jest.Mock).mockResolvedValue(profile);
const result = await getUserProfile(userId);
expect(result).toEqual(profile);
expect(db.user.findUnique).not.toHaveBeenCalled();
});
it('fetches from DB and caches on cache miss', async () => {
(cache.get as jest.Mock).mockResolvedValue(null);
(db.user.findUnique as jest.Mock).mockResolvedValue(profile);
const result = await getUserProfile(userId);
expect(result).toEqual(profile);
expect(db.user.findUnique).toHaveBeenCalledWith({ where: { id: userId } });
expect(cache.set).toHaveBeenCalledWith(`user:profile:${userId}`, profile, 300);
});
it('returns null when DB does not have the user', async () => {
(cache.get as jest.Mock).mockResolvedValue(null);
(db.user.findUnique as jest.Mock).mockResolvedValue(null);
const result = await getUserProfile(userId);
expect(result).toBeNull();
expect(cache.set).not.toHaveBeenCalled();
});
it('gracefully degrades on Redis error', async () => {
(cache.get as jest.Mock).mockRejectedValue(new Error('Redis down'));
(db.user.findUnique as jest.Mock).mockResolvedValue(profile);
const result = await getUserProfile(userId);
expect(result).toEqual(profile);
// Cache set should still be attempted (non‑blocking)
expect(cache.set).toHaveBeenCalled();
});
});
Additional Reference
- Code Review Pack (¥980) on PromptWorks –