Caching Strategies for Full-Stack Applications
Overview
Caching is the single highest-leverage performance optimization available to full-stack developers. Yet most teams either cache nothing or cache everything and spend weeks debugging stale data. This post walks through a layered caching strategy that works in production.
Problem
A React + FastAPI application starts fast. Then content grows. API responses slow down. You add database indexes. That helps — for a while. Eventually you hit a wall: the database is not the bottleneck anymore, the network round trips are.
Every page load triggers:
- DNS lookup
- TLS handshake
- API request
- Database query
- Response serialization
- Network transfer
- Client-side parsing
Caching can short-circuit this chain at every layer.
Solution
Implement caching as four distinct layers, each with its own TTL, invalidation strategy, and failure mode.
Layer 1: Browser Cache (HTTP Headers)
The cheapest cache is the one that never leaves the user's device.
Cache-Control: public, max-age=31536000, immutable
Use this for hashed assets (JS, CSS, images with content-hash filenames). For API responses, use shorter TTLs:
Cache-Control: public, max-age=60, stale-while-revalidate=300
This tells the browser: use the cached version for 60 seconds, then revalidate in the background for up to 5 minutes.
Layer 2: CDN / Edge Cache
Vercel, Cloudflare, and similar platforms cache at the edge. The key headers:
@app.get("/api/posts")
async def list_posts():
posts = await fetch_published_posts()
return JSONResponse(
content=posts,
headers={
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
"CDN-Cache-Control": "public, max-age=300",
}
)
s-maxage controls shared (CDN) cache separately from the browser. This means users hit the edge, not your origin server.
Layer 3: Application Cache (Redis)
For data that is expensive to compute but changes infrequently:
import redis.asyncio as redis
import json
cache = redis.from_url("redis://localhost:6379")
async def get_dashboard_stats():
cached = await cache.get("dashboard:stats")
if cached:
return json.loads(cached)
stats = await compute_expensive_stats()
await cache.setex("dashboard:stats", 300, json.dumps(stats))
return stats
Invalidation strategy: time-based (TTL) for read-heavy data, event-based for write-heavy data.
Layer 4: Database Query Cache
PostgreSQL already caches query plans and frequently accessed pages in shared_buffers. You can help it by:
- Using materialized views for complex aggregations
- Refreshing them on a schedule rather than per-request
- Creating covering indexes so queries are answered from the index alone
CREATE MATERIALIZED VIEW blog_stats AS
SELECT
category,
COUNT(*) AS post_count,
AVG(read_time) AS avg_read_time
FROM posts
WHERE status = 'published'
GROUP BY category;
CREATE UNIQUE INDEX ON blog_stats (category);
Refresh it periodically:
REFRESH MATERIALIZED VIEW CONCURRENTLY blog_stats;
Implementation
Cache Invalidation Pattern
The hardest part of caching is invalidation. A pattern that works:
- Write-through: When data changes, update the cache immediately
- TTL fallback: Even with write-through, set a TTL so stale data self-heals
- Cache tags: Group related cache entries so you can invalidate them together
async def update_post(post_id: str, data: dict):
# Update database
await db.posts.update(post_id, data)
# Invalidate specific caches
await cache.delete(f"post:{post_id}")
await cache.delete("posts:list")
await cache.delete(f"posts:category:{data['category']}")
# Purge CDN cache for this URL
await purge_cdn_path(f"/blog/{data['slug']}")
What NOT to Cache
- User-specific data behind authentication (unless keyed per-user)
- Rapidly changing data (real-time feeds, live metrics)
- Security-sensitive responses (tokens, session data)
Challenges
Cache stampede: When a popular cache key expires, hundreds of requests hit the origin simultaneously. Solution: use stale-while-revalidate or implement a lock so only one request refreshes the cache.
Debugging stale data: Always add a response header indicating cache status:
headers["X-Cache"] = "HIT" if cached else "MISS"
headers["X-Cache-Age"] = str(age_seconds)
Conclusion
Start with HTTP cache headers — they are free and require zero infrastructure. Add Redis when you have expensive computations. Use materialized views for complex aggregations. The goal is not to cache everything, but to cache the right things at the right layer.