Caching expensive results in Redis is one of the most effective ways to make a web application faster: a database query that takes 50 ms becomes a sub-millisecond lookup. But caching brings its own problems: stale data, thundering herds, memory limits. Here are the patterns that work.

What to cache

  • Results of slow or frequent database queries (product lists, dashboards, counts).
  • Rendered fragments or whole pages for anonymous users.
  • Responses from slow third-party APIs (exchange rates, geolocation).
  • Computed values (recommendations, aggregates).
  • Sessions and rate-limit counters (strictly speaking, state rather than cache).

Don’t cache data that must always be exact (account balances during payment), or data that is cheap to compute.

Cache-aside (lazy loading): the default pattern

The application checks the cache first, and loads from the database on a miss:

import json, redis
r = redis.Redis(decode_responses=True)

def get_product(product_id):
    key = f"product:{product_id}:v1"
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)
    product = db.fetch_product(product_id)          # slow path
    r.set(key, json.dumps(product), ex=600)         # cache for 10 minutes
    return product

Only requested data gets cached, and a Redis outage just makes things slower (if you handle connection errors), not broken.

Other patterns

Pattern How Trade-off
Cache-aside App reads cache, fills on miss Simple; first request is slow
Write-through App writes DB and cache together Fresh cache; more write work
Write-behind Write cache, flush to DB later Fast writes; risk of data loss
Refresh-ahead Refresh popular keys before expiry No slow misses; more complexity

TTLs: always set one

Every cache key should expire. TTLs bound staleness, and keep memory in check:

  • seconds to minutes for fast-changing data (stock levels, feeds);
  • hours for slow-changing data (product details, settings);
  • add jitter (a random ±10%) so many keys created together don’t expire at the same moment.
import random
r.set(key, value, ex=600 + random.randint(-60, 60))

Invalidation

“There are only two hard things in computer science: cache invalidation and naming things.” Practical approaches:

  • Delete on write: when a product changes, DEL product:42:v1. Next read reloads it. Simple and reliable.
  • Versioned keys: include a version in the key (product:42:v7), and bump the version on change. Old keys simply expire.
  • Short TTLs where some staleness is acceptable, and no invalidation logic at all.
  • For groups (all keys of a category), store a generation number (category:5:gen), and include it in keys.

Avoid KEYS pattern to find and delete keys in production: it blocks Redis. Use SCAN if you must iterate. See Redis CLI commands.

Cache stampede protection

When a popular key expires, hundreds of requests can miss at once, and all hit the database. Mitigations:

  • Lock on miss: the first request sets a short lock (SET lock:key 1 NX EX 10), and rebuilds; the others wait briefly, or serve stale data.
  • Stale-while-revalidate: store the value with a soft expiry inside it, serve stale data while one worker refreshes.
  • TTL jitter, as above.

Key design and serialization

  • Use readable, namespaced keys: app:product:42:v1.
  • Serialize with JSON (portable), or MessagePack (smaller, faster).
  • Keep values reasonably small. Values of many megabytes slow down Redis, which processes commands one at a time.
  • Use hashes for objects whose fields you update separately.

Memory and eviction

Configure Redis as a cache explicitly:

# /etc/redis/redis.conf
maxmemory 512mb
maxmemory-policy allkeys-lru     # or allkeys-lfu: evict least frequently used

With these settings, Redis evicts old keys instead of failing writes when memory is full. Use a separate Redis instance (or database with noeviction) for data that must not be evicted, such as queues and sessions. See Redis maxmemory and eviction.

Persistence is usually unnecessary for a pure cache: disabling RDB and AOF saves disk I/O. See Redis persistence: RDB and AOF.

Framework integration

Most frameworks have Redis cache backends:

Framework Integration
WordPress Redis Object Cache plugin. See Redis for WordPress
Laravel CACHE_STORE=redis
Django django.core.cache.backends.redis.RedisCache
Node.js ioredis / node-redis with your own helpers
Rails :redis_cache_store

Measure the hit rate

redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses|evicted_keys'

Hit rate = hits / (hits + misses). Below about 80% usually means TTLs are too short, keys are too specific, or the cache is too small (check evicted_keys).

Quick reference

Topic Advice
Pattern Cache-aside by default
Expiry Always TTL, plus jitter
Invalidation Delete on write or versioned keys
Stampedes Lock on miss, stale-while-revalidate
Memory maxmemory + allkeys-lru/lfu
Monitoring Hit rate, evictions, memory

Related: Redis vs Memcached and installing Redis on Ubuntu.

  • Redis
  • Caching
  • Performance