Caching Strategies

Patterns for application caching to improve performance and reduce load.

Overview#

Effective caching dramatically improves application performance. This pattern covers:

  • React cache for request deduplication
  • Next.js data cache
  • Redis caching
  • Stale-while-revalidate pattern
  • Memoization utilities

Prerequisites#

npm install @upstash/redis

Code Example#

React Cache (Request Deduplication)#

1// lib/cache.ts 2import { cache } from 'react' 3import { prisma } from '@/lib/db' 4 5// React cache - deduplicates within a single request 6export const getUser = cache(async (userId: string) => { 7 return prisma.user.findUnique({ where: { id: userId } }) 8}) 9 10// Multiple calls within same request = 1 database query 11export async function UserProfile({ userId }: { userId: string }) { 12 const user = await getUser(userId) // First call - hits DB 13 return <Profile user={user} /> 14} 15 16export async function UserSidebar({ userId }: { userId: string }) { 17 const user = await getUser(userId) // Same request - cached 18 return <Sidebar user={user} /> 19}

Next.js Data Cache#

1// Fetch with caching 2async function getData() { 3 const res = await fetch('https://api.example.com/data', { 4 next: { 5 revalidate: 3600, // Cache for 1 hour 6 tags: ['data'] // For on-demand revalidation 7 } 8 }) 9 return res.json() 10} 11 12// Force fresh data 13async function getFreshData() { 14 const res = await fetch('https://api.example.com/data', { 15 cache: 'no-store' 16 }) 17 return res.json() 18} 19 20// On-demand revalidation 21import { revalidateTag, revalidatePath } from 'next/cache' 22 23export async function updateData() { 24 await saveToDatabase() 25 26 // Revalidate by tag 27 revalidateTag('data') 28 29 // Or by path 30 revalidatePath('/dashboard') 31}

unstable_cache for Database Queries#

1// lib/cache.ts 2import { unstable_cache } from 'next/cache' 3import { prisma } from '@/lib/db' 4 5export const getCachedPosts = unstable_cache( 6 async (userId: string) => { 7 return prisma.post.findMany({ 8 where: { authorId: userId }, 9 orderBy: { createdAt: 'desc' }, 10 take: 10 11 }) 12 }, 13 ['user-posts'], // Cache key prefix 14 { 15 revalidate: 60, // 1 minute 16 tags: ['posts'] // For manual revalidation 17 } 18) 19 20// With dynamic cache key 21export const getCachedPost = unstable_cache( 22 async (postId: string) => { 23 return prisma.post.findUnique({ 24 where: { id: postId }, 25 include: { author: true } 26 }) 27 }, 28 ['post'], // Prefix - actual key will be ['post', postId] 29 { revalidate: 300, tags: ['posts'] } 30)

Redis Caching#

1// lib/redis.ts 2import { Redis } from '@upstash/redis' 3 4const redis = new Redis({ 5 url: process.env.UPSTASH_REDIS_URL!, 6 token: process.env.UPSTASH_REDIS_TOKEN! 7}) 8 9export async function getCached<T>( 10 key: string, 11 fetcher: () => Promise<T>, 12 ttl: number = 3600 13): Promise<T> { 14 // Try cache first 15 const cached = await redis.get<T>(key) 16 17 if (cached !== null) { 18 return cached 19 } 20 21 // Fetch fresh data 22 const data = await fetcher() 23 24 // Cache it 25 await redis.setex(key, ttl, data) 26 27 return data 28} 29 30// Usage 31const user = await getCached( 32 `user:${userId}`, 33 () => prisma.user.findUnique({ where: { id: userId } }), 34 300 // 5 minutes 35) 36 37// Invalidation 38export async function invalidateCache(pattern: string) { 39 const keys = await redis.keys(pattern) 40 if (keys.length > 0) { 41 await redis.del(...keys) 42 } 43}

Stale-While-Revalidate Pattern#

1// lib/swr-cache.ts 2import { Redis } from '@upstash/redis' 3 4const redis = new Redis({ 5 url: process.env.UPSTASH_REDIS_URL!, 6 token: process.env.UPSTASH_REDIS_TOKEN! 7}) 8 9interface CacheEntry<T> { 10 data: T 11 staleAt: number 12 expiresAt: number 13} 14 15export async function swrCache<T>( 16 key: string, 17 fetcher: () => Promise<T>, 18 { staleTime = 60, maxAge = 300 }: { staleTime?: number; maxAge?: number } = {} 19): Promise<T> { 20 const now = Date.now() 21 22 const cached = await redis.get<CacheEntry<T>>(key) 23 24 // Fresh cache hit 25 if (cached && now < cached.staleAt) { 26 return cached.data 27 } 28 29 // Stale cache - return stale data but revalidate in background 30 if (cached && now < cached.expiresAt) { 31 // Revalidate in background (fire and forget) 32 fetchAndCache(key, fetcher, staleTime, maxAge).catch(console.error) 33 return cached.data 34 } 35 36 // Cache miss - fetch fresh 37 return fetchAndCache(key, fetcher, staleTime, maxAge) 38} 39 40async function fetchAndCache<T>( 41 key: string, 42 fetcher: () => Promise<T>, 43 staleTime: number, 44 maxAge: number 45): Promise<T> { 46 const data = await fetcher() 47 const now = Date.now() 48 49 const entry: CacheEntry<T> = { 50 data, 51 staleAt: now + staleTime * 1000, 52 expiresAt: now + maxAge * 1000 53 } 54 55 await redis.setex(key, maxAge, entry) 56 return data 57}

Memoization Utilities#

1// lib/memoize.ts 2 3// Simple memoization for synchronous functions 4export function memoize<T extends (...args: any[]) => any>(fn: T): T { 5 const cache = new Map() 6 7 return ((...args: Parameters<T>) => { 8 const key = JSON.stringify(args) 9 10 if (cache.has(key)) { 11 return cache.get(key) 12 } 13 14 const result = fn(...args) 15 cache.set(key, result) 16 return result 17 }) as T 18} 19 20// Async memoization with TTL 21export function memoizeAsync<T extends (...args: any[]) => Promise<any>>( 22 fn: T, 23 ttl: number = 60000 24): T { 25 const cache = new Map<string, { 26 value: Awaited<ReturnType<T>> 27 expires: number 28 }>() 29 30 return (async (...args: Parameters<T>) => { 31 const key = JSON.stringify(args) 32 const now = Date.now() 33 34 const cached = cache.get(key) 35 if (cached && cached.expires > now) { 36 return cached.value 37 } 38 39 const value = await fn(...args) 40 cache.set(key, { value, expires: now + ttl }) 41 return value 42 }) as T 43} 44 45// Usage 46const expensiveCalculation = memoize((x: number, y: number) => { 47 // Complex computation 48 return x * y 49}) 50 51const fetchUserData = memoizeAsync( 52 async (userId: string) => { 53 return prisma.user.findUnique({ where: { id: userId } }) 54 }, 55 5 * 60 * 1000 // 5 minutes 56)

Cache Warming#

1// lib/cache-warm.ts 2import { getCachedPosts, getCachedPost } from './cache' 3 4export async function warmCache() { 5 // Get popular content 6 const popularPosts = await prisma.post.findMany({ 7 where: { viewCount: { gt: 1000 } }, 8 take: 100 9 }) 10 11 // Pre-cache each post 12 await Promise.all( 13 popularPosts.map(post => getCachedPost(post.id)) 14 ) 15 16 console.log(`Warmed cache with ${popularPosts.length} posts`) 17} 18 19// Run on app startup or via cron 20// warmCache()

Usage Instructions#

  1. Use React cache for request-level deduplication
  2. Use Next.js data cache for fetch requests
  3. Use unstable_cache for database queries with TTL
  4. Use Redis for distributed caching across instances
  5. Implement SWR pattern for better UX with stale data

Best Practices#

  • Cache strategically - Not everything needs caching
  • Set appropriate TTLs - Balance freshness vs performance
  • Invalidate correctly - Use tags for related content
  • Handle cache failures - Always have a fallback to fetch
  • Monitor hit rates - Track cache effectiveness
  • Warm critical paths - Pre-cache popular content
  • Use cache layers - Combine React, Next.js, and Redis caching