Redis Caching: How to Cache Database Queries and Web Page Responses
Database queries are often the slowest part of a web application. A single page load might trigger dozens of queries, each adding latency. Redis changes this equation entirely — serving cached results from memory in under a millisecond, while your database handles a fraction of the load.
Why Cache with Redis?
Redis stores data in RAM, making reads roughly 100x faster than a typical database query. It supports automatic expiration (TTL), atomic operations, and scales horizontally. For read-heavy workloads — product pages, user profiles, dashboards — caching is the single highest-leverage optimization you can make.
The Cache-Aside Pattern
Cache-aside (also called lazy loading) is the most common caching strategy. The application checks Redis first; on a miss it queries the database, stores the result in Redis, then returns it.
# Python example with redis-py
import redis
import json
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_user(user_id):
cache_key = f"user:{user_id}"
# 1. Check cache
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# 2. Cache miss — query DB
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
# 3. Store in cache with 5-minute TTL
r.setex(cache_key, 300, json.dumps(user))
return user
The key operations are GET to check the cache and SETEX (SET with EXpiry) to store with a TTL.
Setting TTLs Effectively
TTL (Time To Live) controls how long data stays cached. Choose based on how often the underlying data changes:
- Static content (product descriptions, blog posts) — 1 hour to 24 hours
- Semi-dynamic (user profiles, search results) — 5 to 30 minutes
- Real-time data (stock prices, live scores) — 1 to 10 seconds
- Computed results (aggregations, reports) — match your data refresh frequency
# Redis CLI examples
# Store with 60-second TTL
SET product:42 '{"name":"Widget","price":9.99}' EX 60
# Check remaining TTL
TTL product:42 # returns seconds remaining
# Store without expiry (persistent)
SET config:feature_flags '{"darkMode":true}'
# Update TTL on an existing key
EXPIRE product:42 120
Caching Web Page Responses
For rendered HTML or API JSON responses, cache the entire output rather than re-running business logic on every request.
# Node.js / Express middleware
const redis = require('redis');
const client = redis.createClient();
async function cacheMiddleware(req, res, next) {
const key = `page:${req.url}`;
const cached = await client.get(key);
if (cached) {
res.setHeader('X-Cache', 'HIT');
return res.send(cached);
}
// Intercept the response to cache it
const originalSend = res.send.bind(res);
res.send = async (body) => {
await client.setEx(key, 300, body); // cache 5 minutes
res.setHeader('X-Cache', 'MISS');
originalSend(body);
};
next();
}
app.get('/api/products', cacheMiddleware, getProducts);
Write-Through vs Write-Behind
Cache-aside only populates on reads. Two write strategies keep the cache fresh proactively:
- Write-through — update both cache and database on every write. Cache is always fresh but writes are slightly slower.
- Write-behind (write-back) — write to cache immediately, flush to database asynchronously. Fastest writes, but risk of data loss if Redis crashes before the flush.
# Write-through example
def update_user(user_id, data):
# Update DB first
db.execute("UPDATE users SET ... WHERE id = %s", user_id)
# Invalidate or update cache
cache_key = f"user:{user_id}"
r.setex(cache_key, 300, json.dumps(data))
Cache Invalidation
The hardest problem in caching. Three practical approaches:
- TTL-based expiry — let the key expire naturally. Simple, but data can be stale up to the TTL duration.
- Delete on write — call
DEL cache_keywhenever the underlying data changes. Next read rebuilds the cache. - Versioned keys — embed a version in the key (e.g.
user:42:v3). Increment version on update; old keys expire naturally.
# Delete on write
def delete_product(product_id):
db.execute("DELETE FROM products WHERE id = %s", product_id)
r.delete(f"product:{product_id}")
r.delete("products:list") # also invalidate list caches
Key Naming Conventions
Consistent key naming prevents collisions and makes debugging easier. Use colons as namespace separators:
user:{id}— single user recordproduct:{id}:reviews— reviews for a productsearch:{query}:{page}— paginated search resultspage:/blog/my-post— rendered page cache
Key Takeaways
- Cache-aside is the safest starting point — check Redis, fall back to DB on miss
- SETEX stores data with automatic expiration — always set a TTL
- Invalidate on write keeps cache consistent with the source of truth
- Cache at the right level — query results, API responses, and rendered HTML all benefit differently
- Monitor hit rate — a cache hit rate below 80% suggests poor key design or TTLs that are too short
Redis caching is one of the fastest wins in backend performance. Even a simple cache-aside layer on your most expensive queries can cut response times dramatically and take significant load off your database.