Caching Strategies with Claude Code: Redis Patterns and Cache Invalidation
Source: Dev.to – Caching Strategies with Claude Code, Redis Patterns, and Cache Invalidation
Caching Rules
What to Cache
| Data Type | TTL |
|---|---|
| Master data (user roles, config, feature flags) | 5 min |
| Expensive computations (aggregations, reports) | 15 min |
| User sessions | Session expiry |
| API responses from external services | 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 a write
Cache Keys
Format:
{service}:{entity}:{id}:{version} {service}:{entity}:list:{filter_hash}Examples:
user:profile:123 product:list:abc123 config:features:v1Important: Never use raw user input in cache keys (injection risk).
Invalidation Strategy
- Write‑through – update the cache on every write (ensures consistency).
- TTL‑based – use short TTLs when eventual consistency is acceptable.
- Event‑based – invalidate on domain events (preferred 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';
// Create a singleton Redis client – configure connection options as needed.
const client = createClient();
client.on('error', (err) => logger.error('Redis error', err));
export const cache = {
/**
* Retrieve a value from the cache.
* @param key Cache key
* @returns Parsed value or `null` if missing / on error
*/
async get<T>(key: string): Promise<T | null> {
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; // treat errors as a cache miss
}
},
/**
* Store a value in the cache.
* @param key Cache key
* @param value Value to cache (will be JSON‑stringified)
* @param ttlSeconds Time‑to‑live in seconds
*/
async set(key: string, value: unknown, ttlSeconds: number): Promise<void> {
try {
const serialized = JSON.stringify(value);
await client.set(key, serialized, { EX: ttlSeconds });
} catch (err) {
logger.error('Cache set error', err);
// Swallow the error – the request should continue
}
},
/**
* Delete one or more keys from the cache.
* @param key Cache key or array of keys
*/
async del(key: string | string[]): Promise<void> {
try {
if (Array.isArray(key)) {
await client.del(...key);
} else {
await client.del(key);
}
} catch (err) {
logger.error('Cache delete error', err);
}
},
/**
* Check whether a key exists in the cache.
* @param key Cache key
* @returns `true` if the key exists, otherwise `false`
*/
async exists(key: string): Promise<boolean> {
try {
const result = await client.exists(key);
return result === 1;
} catch (err) {
logger.error('Cache exists error', err);
return false; // treat errors as “does not exist”
}
},
};- Never throw on Redis errors — the methods return
null,void, orfalseas appropriate. - All errors are logged via
logger.tsbut do not crash the application.
Cache‑Aside Pattern for getUserProfile()
// Example implementation
import { cache, db, logger } from "./services";
interface UserProfile {
id: string;
name: string;
email: string;
// …other fields
}
/**
* Retrieves a user profile using the cache‑aside pattern.
*
* @param userId - The identifier of the user.
* @returns The user profile or `null` if the user does not exist.
*/
export async function getUserProfile(
userId: string,
): Promise<UserProfile | null> {
const cacheKey = `user:profile:${userId}`;
// 1️⃣ Try cache first
const cached = await cache.get<UserProfile>(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) // TTL = 300 seconds (5 min)
.catch(logger.error);
return profile;
}Behaviour
- Cache hit – Returns the cached data immediately.
- Cache miss – Retrieves the profile from the database, caches it for 5 minutes, then returns it.
- Redis error – The
catchoncache.setlogs the error; the function still returns the DB result, providing graceful degradation.
Cache Invalidation Logic (User Profile Update)
- Invalidate
user:profile:{userId}and any list caches that contain the user. - Use a domain event (
user.updated) instead of calling the cache directly from the service.
Event handler
// src/events/userUpdated.ts
import { cache } from '../lib/cache';
import logger from '../lib/logger';
import { Redis } from 'ioredis'; // if you need the raw client for scanning
// If you need the raw Redis client, expose it from your cache lib
// const client: Redis = cache.getClient();
export async function handleUserUpdated(event: { userId: string }) {
const { userId } = event;
const profileKey = `user:profile:${userId}`;
// 1️⃣ Invalidate the profile cache
await cache.del(profileKey).catch(logger.error);
// 2️⃣ Invalidate list caches (wild‑card)
const pattern = 'user:list:*';
try {
// Using the low‑level client to iterate over matching keys
const stream = cache.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);
}
}Service that emits the event
// src/services/userService.ts
import { emit } from '../events/bus';
import { PrismaClient, User } from '@prisma/client';
const db = new PrismaClient();
export async function updateUserProfile(
userId: string,
data: Partial<User>
) {
const updated = await db.user.update({
where: { id: userId },
data,
});
// Emit a domain event so cache logic stays decoupled
await emit('user.updated', { userId });
return updated;
}Key take‑aways
- Domain events keep cache concerns out of business logic.
- Use a wild‑card scan (
SCAN) to find and delete related list keys efficiently. - Always log errors from cache operations to avoid silent failures.
Paginated User List Caching (Cursor‑Based)
- Cache key:
user:list:{cursor}:{limit} - TTL: 2 min (shorter because the list can become stale after mutations)
- 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
import { db } from './db'; // Prisma (or your ORM) instance
/**
* Retrieves a paginated list of users, using a cursor‑based approach.
* Results are cached for 2 minutes.
*
* @param cursor - The ID of the last item from the previous page (empty for the first page).
* @param limit - Number of items to fetch.
* @returns - An array of user records.
*/
export async function getUserList(
cursor: string,
limit: number
): Promise<any[]> { // Replace `any` with your User type
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;
}
/**
* Removes every cached user‑list entry.
* Intended to be called after mutations that affect the list (e.g., create/delete).
*/
export async function invalidateUserListCaches(): Promise<void> {
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);
}
}Usage – Call
invalidateUserListCaches()from your domain‑event handlers (e.g.,user.created,user.deleted) so that subsequent pagination requests fetch fresh data.
Cache‑Check Hook (.claude/hooks/check_cache.py)
The hook scans route‑handler files for direct Prisma findMany calls that are not wrapped with cache logic. If such a call is found, it prints a warning to stderr.
# .claude/hooks/check_cache.py
import json
import re
import sys
# Read the JSON payload from stdin
data = json.load(sys.stdin)
# Extract the relevant fields
content = data.get("tool_input", {}).get("content", "") or ""
fp = data.get("tool_input", {}).get("file_path", "")
# ----------------------------------------------------------------------
# Only run the check for route‑handler files (ignore repositories, etc.)
# ----------------------------------------------------------------------
if not fp or ("routes" not in fp and "controllers" not in fp):
sys.exit(0)
# --------------------------------------------------------------
# Flag Prisma findMany calls that lack surrounding cache logic
# --------------------------------------------------------------
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 exits silently for non‑route files and reports a warning only when a Prisma findMany call is detected without any cache.get or cache.set usage.
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';
import type { UserProfile } from '../src/types';
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