Redis

Redis Caching: How to Cache Database Queries and Web Page Responses

June 9, 2026
7 min read

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_key whenever 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 record
  • product:{id}:reviews — reviews for a product
  • search:{query}:{page} — paginated search results
  • page:/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.