Caching Strategies with Claude Code: Redis Patterns and Cache Invalidation

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

Source: Dev.to – Caching Strategies with Claude Code, Redis Patterns, and Cache Invalidation

Caching Rules

What to Cache

Data TypeTTL
Master data (user roles, config, feature flags)5 min
Expensive computations (aggregations, reports)15 min
User sessionsSession expiry
API responses from external services1 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:v1
  • Important: 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, or false as appropriate.
  • All errors are logged via logger.ts but 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 catch on cache.set logs 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

  1. Domain events keep cache concerns out of business logic.
  2. Use a wild‑card scan (SCAN) to find and delete related list keys efficiently.
  3. 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 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
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
0 views
Back to Blog

Related posts

Read more »