All writing
redis fastapi caching performance

Redis caching in FastAPI: practical setup and pitfalls

Ayush Kaushik 7 min read
Redis caching in FastAPI: practical setup and pitfalls

Redis caching in FastAPI: practical setup and pitfalls is something I’ve wrestled with in production for over two years. It’s not just about slapping a cache layer on - it’s about knowing where it helps, where it hurts, and how to keep it from silently breaking your system. Here’s what I’ve learned running FastAPI services that handle tens of thousands of requests per day, backed by Redis for session storage and result caching.

How do I set up Redis with FastAPI for session caching?

Start with a lightweight async Redis client. I use redis-py with asyncio support because it plays nicely with FastAPI’s async endpoints. First, install the deps: pip install redis[async] python-jose[cryptography]. Then create a Redis connection pool at app startup - never create a new connection per request. That’s a rookie mistake I made early on, and it killed performance under load.

Here’s how I initialize it in main.py:

from fastapi import FastAPI
from redis.asyncio import Redis
import aioredis
app = FastAPI()
@app.on_event("startup")
async def startup():
app.state.redis = Redis.from_url("redis://localhost:6379", encoding="utf-8", decode_responses=True)
@app.on_event("shutdown")
async def shutdown():
await app.state.redis.close()

For sessions, I store JSON-serialized user data with a TTL. Here’s a dependency that gets or sets session data:

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
security = HTTPBearer()
async def get_session(token: HTTPAuthorizationCredentials = Depends(security)):
redis: Redis = app.state.redis
session_data = await redis.get(f"session:{token.credentials}")
if not session_data:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session")
return json.loads(session_data)

I set sessions to expire after 24 hours - short enough to limit risk, long enough to avoid annoying users. But I’ve seen teams set TTLs to weeks, then wonder why old sessions linger after a password reset. Don’t do that. Always invalidate on critical events.

How do I use Redis as a result cache for expensive API calls?

This is where Redis shines - caching the output of slow external APIs or heavy DB queries. I wrap expensive functions with a decorator that checks Redis first. The key includes the function name and hashed arguments to avoid collisions.

import hashlib
import json
from functools import wraps
def cache_result(ttl: int = 300):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
redis: Redis = app.state.redis
key = f"cache:{func.__name__}:{hashlib.sha256(json.dumps((args, kwargs), sort_keys=True).encode()).hexdigest()}"
cached = await redis.get(key)
if cached:
return json.loads(cached)
result = await func(*args, **kwargs)
await redis.set(key, json.dumps(result), ex=ttl)
return result
return wrapper
return decorator

I use this on endpoints that call third-party ML models or aggregate analytics. A 5-minute TTL works for most - stale data is better than no data during a spike. But I learned the hard way: never cache POST/PUT/DELETE results. Only cache idempotent GETs. I once cached a payment confirmation endpoint - yeah, don’t be me.

What are effective cache invalidation strategies in FastAPI?

Time-based TTLs are lazy. Smart invalidation is better. I use two patterns: explicit deletes on data mutation, and tag-based invalidation for related data.

When a user updates their profile, I delete their session and any cached user-specific data:

@app.put("/users/{user_id}")
async def update_user(user_id: int, user: UserUpdate):
# ... update DB ...
redis: Redis = app.state.redis
await redis.delete(f"session:{user_id}") # if tied to user
await redis.delete(f"cache:get_user:{user_id}")
await redis.delete(f"cache:get_user_posts:{user_id}")
return {"status": "updated"}

For broader invalidation - like when a product price changes and affects multiple caches - I use tags. I maintain a Redis set per tag, and cache keys are added to those sets. On invalidation, I fetch all keys in the tag and delete them. It adds complexity, but it’s worth it for frequently related data.

How do I handle Redis connection failures gracefully in FastAPI?

Assume Redis will fail. It will. Network blips, restarts, OOM kills - they happen. I wrap Redis calls in a circuit breaker pattern. If three consecutive calls fail, I switch to a fail-open mode: skip the cache and go straight to the source, but log loudly and alert.

Here’s a simplified version:

class RedisCache:
def __init__(self, redis: Redis):
self.redis = redis
self.failures = 0
self.max_failures = 3
self.open_until = 0
async def get(self, key: str):
if time.time() < self.open_until:
return None # fail-open: skip cache
try:
return await self.redis.get(key)
except Exception:
self.failures += 1
if self.failures >= self.max_failures:
self.open_until = time.time() + 60 # open for 60s
return None

I also set timeouts on the Redis client itself - socket_connect_timeout=2, socket_timeout=2 - so a hung Redis doesn’t tie up workers. And I monitor: if fail-open mode activates more than once an hour, I page someone. Cache should never be a single point of failure.

Redis vs in-memory caching for FastAPI: when should I use which?

I’ve used both. In-memory (like lru_cache or cachetools) is blazing fast - no network hop. But it’s per-process. In a multi-worker setup (Uvicorn with Gunicorn, say), each worker has its own cache. That means cache misses multiply, and memory usage scales with worker count. I’ve seen services OOM because someone cached 100MB of data in-memory across 8 workers.

Redis adds ~1ms latency per call (localnet), but it’s shared. One source of truth. For session stores or result caches that need consistency across instances, Redis wins. For tiny, read-heavy, worker-local data (like config or static lookup tables), in-memory is fine - just know its limits.

I use in-memory for things like API key validation (low cardinality, infrequent change). For anything that scales with users or requests - sessions, API results, rate limiting - Redis is the safer choice.

How do I monitor Redis cache hit rates in production?

You can’t optimize what you don’t measure. I track two metrics: hit rate (hits / (hits + misses)) and memory usage. I use redis-cli info stats scraped by Prometheus, or better yet, export via redis_exporter.

In FastAPI, I instrument my cache layer to increment counters:

from prometheus_client import Counter, Gauge
CACHE_HITS = Counter('cache_hits_total', 'Total cache hits')
CACHE_MISSES = Counter('cache_misses_total', 'Total cache misses')
CACHE_MEMORY = Gauge('cache_memory_bytes', 'Redis memory used')
# In get/set methods:
# On hit: CACHE_HITS.inc()
# On miss: CACHE_MISSES.inc()
# Periodically: CACHE_MEMORY.set(await redis.info('memory')['used_memory'])

A healthy hit rate for result caching is 60-80%. Below 40%, I look at TTLs or key design. Above 90%, I wonder if I’m caching too aggressively - maybe the data isn’t that expensive to compute. I alert if hit rate drops suddenly or memory grows past 80% of maxmemory.

I’ve been bitten by assuming “it’s just cache” - until a Redis OOM killed session auth during a Black Friday spike. Now I treat cache like any other critical service: monitor it, test failure modes, and never let it become a silent bottleneck.

FAQ

Can I use Redis for rate limiting in FastAPI?
Yes - I use a token bucket or fixed window counter stored in Redis with short TTLs (e.g., 60s). It’s atomic and shared across workers. Just watch for clock skew if you distributed Redis.

Should I cache database query results directly?
Only if the query is expensive and the data doesn’t change often. I prefer caching at the API layer - it’s easier to invalidate based on business events than table changes.

What’s a safe starting TTL for API result caches?
Start with 5-15 minutes for most external API calls. Adjust based on data volatility and staleness tolerance. Monitor hit rate and backend load to tune.

How do I test cache logic in FastAPI tests?
I use fakeredis in pytest - it mimics Redis API without needing a real server. I inject it via dependency override in tests. See my guide on FastAPI Testing With Pytest: Practical Patterns for patterns.

Key Takeaways

  • Initialize Redis once at app startup - never per request - to avoid connection storms.
  • Cache only idempotent GET responses; never cache mutating operations.
  • Use explicit deletes or tag-based invalidation for data mutations - don’t rely solely on TTL.
  • Wrap Redis calls in failure handling (timeouts, circuit breakers) to avoid cascading failures.
  • Prefer Redis over in-memory caching for shared state; use in-memory only for tiny, worker-local data.
  • Monitor hit rate and memory - alert on sudden drops or sustained high usage.
  • Treat cache as infrastructure: test failure modes, scale it, and don’t let it become a silent single point of failure.

Working on something similar?

If you're building backend or AI systems and want a second set of senior eyes, let's talk.

Keep reading

Related articles