Caching Strategies with Claude Code: Redis Patterns and Cache Invalidation

Published: (March 11, 2026 at 12:07 AM EDT)
6 min read
Source: Dev.to

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/false instead.
  • Errors are logged via logger.ts but 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

  1. Cache hit → returns cached data.
  2. Cache miss → fetches from DB, stores result with TTL 5 min, returns it.
  3. 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 using SCAN.
// 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 –

0 views
Back to Blog

Related posts

Read more »