DH
5 min read

Caching Strategies with Redis for Next.js and FastAPI: When and What to Cache

Decision framework and working patterns for when and what to cache in a Redis-backed stack. Avoid costly staleness bugs and database fires with principled caching.

Caching Strategies with Redis for Next.js and FastAPI: When and What to Cache

Caching is where "I know what it is" and "I know when to use it" diverge—often expensively. Most teams either cache too eagerly (introducing staleness bugs) or too late (setting their database on fire). This guide gives you a decision framework and working patterns for a Redis-backed Next.js + FastAPI stack.


The Core Question: Should This Even Be Cached?

Before touching Redis, answer three questions:

  1. Is this data read significantly more than it's written? Roughly equal read/write ratios don't justify caching complexity.
  2. Is regeneration costly? Expensive DB joins, third-party API calls, and heavy computation are cache candidates. A single indexed primary-key lookup is not.
  3. Can you tolerate stale data, and for how long? This defines your TTL. A product catalog tolerates 60 seconds; a user's account balance does not.

If all three are yes, proceed. Otherwise, a well-indexed Postgres query is usually sufficient.


Caching in FastAPI

Your FastAPI backend is where most caching decisions live. Here's a working pattern using redis.asyncio:

# dependencies/cache.py
import redis.asyncio as redis
import os

redis_client = redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379"))
# routes/products.py
import json
from fastapi import APIRouter
from dependencies.cache import redis_client

router = APIRouter()
CACHE_TTL = 60 # seconds

@router.get("/products/{category}")
async def get_products(category: str):
cache_key = f"products:{category}"

cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)

products = await fetch_products_from_db(category)
await redis_client.setex(cache_key, CACHE_TTL, json.dumps(products))
return products

This cache-aside pattern is explicit and easy to reason about. Structured cache keys (e.g., products:{category}) matter for later invalidation.

Cache-worthy in FastAPI:

  • Aggregated query results (dashboards, counts)
  • Third-party API responses (exchange rates, geolocation)
  • Deterministic computed results
  • User-scoped data with short TTLs (user:{id}:profile, 30–120s)

Avoid caching:

  • Write-heavy records
  • Strict-consistency data (transactions, reservations)
  • Auth tokens (use Redis for session storage instead, with different expiry logic)

Handling Failure and Cache Stampede

Redis failures shouldn't cascade to your database. Wrap cache operations in a timeout and fallback:

@router.get("/products/{category}")
async def get_products(category: str):
cache_key = f"products:{category}"

try:
cached = await asyncio.wait_for(
redis_client.get(cache_key), timeout=1.0
)
if cached:
return json.loads(cached)
except (asyncio.TimeoutError, redis.ConnectionError):
pass # Fall through to DB fetch

products = await fetch_products_from_db(category)

# Best-effort cache write; don't fail the response if Redis is down
try:
await asyncio.wait_for(
redis_client.setex(cache_key, CACHE_TTL, json.dumps(products)),
timeout=1.0
)
except (asyncio.TimeoutError, redis.ConnectionError):
pass

return products

To mitigate cache stampede (thundering herd when a hot key expires), use probabilistic early expiration:

import random
import time

@router.get("/products/{category}")
async def get_products(category: str):
cache_key = f"products:{category}"
cached = await redis_client.get(cache_key)

if cached:
ttl = await redis_client.ttl(cache_key)
# Recompute at 80% of TTL with 10% random jitter
if ttl < CACHE_TTL * 0.2 and random.random() < 0.1:
products = await fetch_products_from_db(category)
await redis_client.setex(cache_key, CACHE_TTL, json.dumps(products))
return json.loads(cached)

products = await fetch_products_from_db(category)
await redis_client.setex(cache_key, CACHE_TTL, json.dumps(products))
return products

This spreads refresh load across the window instead of hammering the DB when TTL hits zero.


Cache Invalidation in FastAPI

TTL expiry handles most cases, but immediate invalidation is needed after writes:

@router.put("/products/{product_id}")
async def update_product(product_id: int, payload: ProductUpdate):
updated = await update_product_in_db(product_id, payload)

# Invalidate affected category cache
await redis_client.delete(f"products:{updated.category}")

return updated

For broader invalidation, use SCAN with a match pattern—it's safer than KEYS * and won't block the event loop:

cursor = 0
pattern = "products:*"

while True:
cursor, keys = await redis_client.scan(cursor, match=pattern, count=100)
if keys:
await redis_client.delete(*keys)
if cursor == 0:
break

Caching in Next.js

Route Handler and Server Component caching: Next.js has its own fetch cache. Control it explicitly:

// app/api/featured/route.ts
export async function GET() {
const res = await fetch(`${process.env.API_URL}/products/featured`, {
next: { revalidate: 60 }, // ISR-style revalidation
});
return Response.json(await res.json());
}

This works for public, semi-static content without Redis.

When Redis matters on the Next.js side: Use it for server-side caching that persists across deployments, scales horizontally, or is shared across multiple Next.js instances (common in containerized setups).

// lib/cache.ts
import { createClient } from "redis";

const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
export default client;
// app/api/leaderboard/route.ts
import cache from "@/lib/cache";

export async function GET() {
const cached = await cache.get("leaderboard:global");
if (cached) return Response.json(JSON.parse(cached));

const data = await computeLeaderboard();
await cache.setEx("leaderboard:global", 30, JSON.stringify(data));
return Response.json(data);
}

TTL Reference

Data typeTTLNotes
Product catalog60–300sInvalidate on write
Search results30–60sKey on query hash
User profile30–120sKey on user ID
Leaderboard15–30sAcceptable lag
Third-party APIMatch source TTLHonor upstream headers
Session data24h–7dSliding expiry on access

Docker Compose Setup

services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

Set maxmemory explicitly; unbounded Redis consumption breaks shared hosts. The allkeys-lru eviction policy discards least-recently-used keys when full, preventing errors and avoiding stale data accumulation. Pair this with intentional TTLs—let expiration handle correctness, and let LRU handle overflow as a safety valve only.


The Principle That Matters

Cache the boundary between expensive and cheap. In a Next.js + FastAPI stack, that boundary usually sits at the FastAPI response layer, just above the database. Get that right, and nearly everything else defers until you actually need it.

Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps