Redis Caching Strategies Every Backend Engineer Should Know
TL;DR
Redis is a high-performance, in-memory data store that is widely used as a cache layer. To get the most value from Redis you must choose appropriate caching strategies (cache-aside, read-through, write-through/behind, negative caching, TTL policies, eviction strategies), handle concurrency and cache stampedes, design for memory efficiency, ensure HA and correct shard/cluster design, and use Redis features (Lua scripts, modules, pub/sub, Streams) to implement robust, consistent behavior. This article provides a deep-dive into these strategies, trade-offs, code examples, and operational best practices for production systems.
Table of contents
- Background and brief history
- Core Redis concepts relevant to caching
- Why caching matters (metrics & goals)
- Caching strategies (detailed)
- Cache-aside
- Read-through
- Write-through
- Write-behind (write-back)
- Write-around
- TTL & eviction strategies
- Negative caching
- Cache stampede mitigation
- Cache invalidation strategies (lazy, eager, tagging)
- Redis-specific features & patterns to implement strategies
- Atomic operations & Lua scripts
- Hashes for memory-efficiency
- Modules: RedisJSON, RedisBloom, RedisGears, RediSearch
- Pub/Sub & Streams for cache invalidation
- Sharding, clustering, and high availability
- Data modeling & serialization for efficient caching
- Monitoring, testing, and benchmarking
- Security, deployment, and operational best practices
- Practical examples / code snippets
- Case studies and real-world patterns
- Future directions & trends
- Best-practice checklist
- Further reading
Background and brief history
Redis (REmote DIctionary Server) was created by Salvatore Sanfilippo in 2009. It evolved from a simple key-value store into a rich in-memory data platform with multiple data types (strings, hashes, lists, sets, sorted sets), powerful commands, persistence options (RDB, AOF), replication, cluster mode, Sentinel for HA, and an extensible module system. Redis’ sub-millisecond performance and flexible data model make it ideal as a caching layer for backend systems.
Core Redis concepts relevant to caching
- Data types: strings, hashes, lists, sets, sorted sets (ZSET), bitmaps, HyperLogLog, streams.
- TTL and expiration: EXPIRE, PEXPIRE, EXPIREAT.
- Eviction policies (when maxmemory exceeded): noeviction, allkeys-lru, volatile-lru, allkeys-lfu, volatile-lfu, volatile-ttl, volatile-random, allkeys-random.
- Persistence: RDB (snapshotting), AOF (append-only file) — trade-offs for durability vs speed.
- Replication and HA: master-replica replication, Sentinel for failover, Cluster for sharding and scaling.
- Atomicity: single commands are atomic; Lua scripting provides multi-command atomicity.
- Pub/Sub, Streams, and modules (RedisJSON, RedisBloom, RedisTimeSeries, RedisGraph).
Why caching matters (metrics & goals)
Primary goals:
- Reduce latency for end users.
- Reduce load on primary storage (databases, external APIs).
- Improve throughput and reliability.
Key metrics:
- Cache hit rate = hits / (hits + misses)
- Latency (p50/p95/p99)
- Throughput (ops/sec)
- Eviction rates
- Memory usage and fragmentation
- Errors (timeouts, timeouts to origin)
Trade-offs: consistency vs performance, memory cost vs hit rate, complexity vs predictability.
Caching strategies (detailed)
1) Cache-aside (Lazy loading)
Pattern:
- On read: application checks cache. If miss, fetch data from DB, populate cache, return result.
- On write: update DB, then invalidate/delete or update cache.
Pros:
- Simple, flexible, minimal coupling to Redis.
- Cache only what is needed.
Cons:
- Potential for stale reads unless write path invalidates cache correctly.
- Cache stampede on high concurrency for popular keys.
When to use:
- General-purpose caching for queries or objects where eventual consistency is acceptable.
Example (pseudo):
- GET key from Redis
- If found, return
- Else fetch from DB, SET key with TTL, return
2) Read-through
Pattern:
- Cache is augmented with logic (e.g., a caching proxy or a library) so that reads go to cache and cache handles fetching from DB on miss.
- Application reads from cache only.
Pros:
- Centralized responsibility for cache filling.
- Can simplify application code.
Cons:
- Adds coupling, may increase latency if the caching layer synchronously fetches DB.
When to use:
- When using client libraries or infrastructures that support read-through (e.g., some caching frameworks).
3) Write-through
Pattern:
- All writes go via the cache. The cache writes synchronously to the backing store before acknowledging.
- Ensures cache and DB are synchronized at write time.
Pros:
- Stronger consistency: cache and DB are always in sync at write completion.
Cons:
- Higher write latency (need to write DB in write flow).
- More load on DB (writes can’t be batched easily).
When to use:
- When consistency on writes must be maintained, and write latency is acceptable.
4) Write-behind (Write-back)
Pattern:
- Writes are made to the cache and acknowledged immediately; the cache asynchronously persists to the DB later.
- Can batch multiple writes to reduce DB load.
Pros:
- Lower write latency, can batch writes for efficiency.
Cons:
- Risk of data loss if cache fails before persisting changes.
- More complex: handling order, retries, batching semantics.
When to use:
- When write latency is critical and occasional loss is acceptable or when cache durability mechanisms are strong.
5) Write-around
Pattern:
- Write operations bypass cache and go directly to DB. Read populates cache on misses.
- Helps avoid caching items that are written once and never read.
Pros:
- Keeps cache space focused on popular reads.
Cons:
- If written items are read soon after, read path will miss and fetch from DB.
6) TTL & Expiration
Using TTL reduces staleness and bounds memory usage. Common approaches:
- Fixed TTL: e.g., 5m for all cache keys.
- Sliding TTL (touch on access) for LRU-like behavior.
- Adaptive TTLs per key type or user.
- Stochastic TTLs (add jitter to TTLs) to avoid mass expiration (thundering expiration).
Commands: EXPIRE, PEXPIRE, SET key value EX px NX, etc.
7) Eviction policies
When Redis reaches maxmemory, it evicts keys according to configured policy. Choose based on access pattern:
- allkeys-lru: evict least recently used among all keys.
- volatile-lru: LRU among keys with TTL only.
- allkeys-lfu: evict least frequently used—good when popularity distribution is stable.
- volatile-ttl: evict keys with nearest expiration.
- noeviction: reject writes when out of memory.
Considerations:
- LRU approximations in Redis are not perfectly precise but good enough.
- You must set maxmemory and an eviction policy that fits your data characteristics.
8) Negative caching
Cache "not found" or "null" responses with a short TTL to avoid repeatedly hitting DB for missing items. Use short TTL to allow eventual creation.
Example:
- If DB returns no row, cache a special tombstone value (e.g., "NULL") for 60 seconds.
9) Cache stampede (thundering herd) mitigation
Problem: many clients attempt to fill cache simultaneously on expiration leading to high origin load.
Solutions:
- Locking / singleflight: coordinate so only one client fetches, others wait for result (SETNX-based lock or distributed lock).
- Request coalescing: queue waiters until the result is available.
- Probabilistic early expiration: expire keys slightly before TTL with random offset so requests are spread out.
- Stale-while-revalidate: return stale data while a background refresh happens (dual TTL: fresh TTL + stale TTL).
- Leases: a client owns the right to refresh for a period (GCache's approach).
- Use Redis’ built-in features: Lua script to perform get-or-set atomically.
10) Cache invalidation strategies
- Time-based: rely on TTLs (cheap, eventual).
- Explicit invalidation: application invalidates cache on writes (DELETE or SET overwrite).
- Versioning: store version token; on change bump token so stale keys are ignored.
- Tagging (group invalidation): maintain sets of keys per tag to invalidate group of items.
- Event-driven: use pub/sub or Streams to publish invalidation events that subscribers apply.
Redis-specific features & patterns to implement strategies
Atomic get-or-set using Lua
Lua script ensures that a get/miss/fetch/set sequence is atomic to avoid races.
Example Lua script (simple):
1-- KEYS[1] = key, ARGV[1] = ttl (seconds), ARGV[2] = value-if-miss (string)
2local val = redis.call("GET", KEYS[1])
3if val then
4 return val
5end
6redis.call("SET", KEYS[1], ARGV[2], "EX", ARGV[1])
7return ARGV[2]In practice, you’d have the application fetch the value from origin if Lua doesn't contain it; but you can use Lua to set a placeholder lock.
Distributed locking (SET NX EX)
Use SET key value NX PX milliseconds to implement locks. Example:
SET lock:<key> <token> NX PX 3000On refresh or release check that token matches to avoid releasing another client's lock.
Note: Redlock (algorithm by Redis author) has controversy. Use simple locks or libraries (e.g., Redisson for Java) if you understand the trade-offs. For single Redis instance, SET NX is typically enough. For correctness across multiple nodes, careful consideration is required.
Singleflight / request coalescing
Libraries exist in several languages:
- Go: golang.org/x/sync/singleflight
- Node/JS: p-memoize or custom constructions
- Use Redis to signal refresh in progress and a list of waiting clients, or keep waiters in-process for single-process caches.
Negative caches and tombstones
Example:
- Store "MISSING" with TTL 60s. Application treats this as non-existent.
Stale-while-revalidate pattern
Store two values: the data and a separate expiry timestamp or keep primary TTL and an extra stale TTL. Example workflow:
- If key TTL > 0 and data fresh => return
- If TTL expired but within stale TTL => return stale data and trigger async refresh to update cache
- If fully expired => block and fetch
This keeps availability and prevents origin overload.
Bloom filters for negative caching
Use RedisBloom module to test likely-non-existence cheaply and avoid hitting DB for items that don’t exist in the dataset. Good for huge keyspaces of mostly-missing keys.
Tagging & group invalidation
Implement by maintaining sets:
- For each tag, maintain a set of keys keyed by tag: SADD tag:color key1...
- To invalidate tag, iterate and DEL keys (or use unlink for async deletion). Be cautious with large sets (memory & performance).
Lua-based atomic cache update + DB update coordination
Use scripts to atomically update multiple keys or maintain invariants.
Hashes for many small fields
Redis hash stores many fields under one key, which is much more memory efficient than thousands of small top-level keys, because hash fields are packed in a single object. Use HGET/HSET for objects with many fields or many small keys per logical entity.
Sharding, clustering, and high availability
- Redis Cluster: automatic sharding by hash slots (16384 slots). Keys that must be operated on together should share a hash tag {tag}.
- Cross-slot commands are not allowed in cluster mode if keys hash to different slots—use hash tags to colocate related keys.
- When caching per-user data, shard by user ID using Cluster.
- Use replication and Sentinel or Cluster for failover. Sentinel watches and promotes replicas when master fails.
- Consider read replicas for read scaling (but remember eventual consistency).
- For strong consistency, write to master and read master or handle read-after-write explicitly.
Data modeling & serialization for efficiency
- Serialize compactly: MessagePack, Protocol Buffers, or Redis' native types (hash) instead of verbose JSON.
- Use hashes to store object fields to reduce key overhead.
- Compress large values if network/memory vs CPU trade-off is favorable (lz4).
- Avoid storing extremely large values in Redis — chunk them or use a backing object store (S3) and cache metadata.
Memory considerations:
- Each key has overhead. Use HMSET for many fields or ZIPLIST encoding for small hashes (Redis handles encoding internally).
- Monitor memory fragmentation and maxmemory.
Monitoring, testing, and benchmarking
Monitoring:
- Use INFO command for metrics: keyspace_hits, keyspace_misses, used_memory, instantaneous_ops_per_sec.
- LATENCY and SLOWLOG for latency analysis.
- Use RedisInsight for GUI-based inspection (or Prometheus exporters).
Testing & benchmarking:
- redis-benchmark, memtier_benchmark, YCSB for workloads.
- Load test both cache and origin to identify bottlenecks.
- Simulate eviction and TTL expiration patterns.
Security and deployment considerations
- Use AUTH and ACLs to limit commands.
- Run Redis in a private network or protected mode.
- Use TLS for connections if remote access is required.
- Limit dangerous commands (FLUSHALL, CONFIG, DEBUG) via ACL.
- Harden OS and configure memory limits (maxmemory), swap disabled or tuned.
Practical examples / code snippets
Example 1 — Python cache-aside (redis-py)
1import redis
2import json
3from typing import Optional
4
5r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
6
7def get_user(user_id: str) -> Optional[dict]:
8 key = f"user:{user_id}"
9 cached = r.get(key)
10 if cached:
11 if cached == "__MISSING__":
12 return None
13 return json.loads(cached)
14 # miss -> fetch from DB (pseudo)
15 user = fetch_user_from_db(user_id) # implement DB fetch
16 if user:
17 r.set(key, json.dumps(user), ex=300) # 5m TTL
18 else:
19 r.set(key, "__MISSING__", ex=60) # negative cache
20 return userExample 2 — Atomic get-or-set using Lua from Node.js (ioredis)
1const Redis = require('ioredis');
2const redis = new Redis();
3
4const GET_OR_SET = `
5 local val = redis.call("GET", KEYS[1])
6 if val then
7 return val
8 end
9 redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[2])
10 return ARGV[1]
11`;
12
13async function cacheAsideGet(key, ttlSeconds, fetchFn) {
14 const cached = await redis.get(key);
15 if (cached) return JSON.parse(cached);
16 // Acquire fetch lock: use SET NX PX
17 const lockKey = `lock:${key}`;
18 const lockToken = "token-" + Math.random().toString(36).slice(2);
19 const gotLock = await redis.set(lockKey, lockToken, "NX", "PX", 3000);
20 if (gotLock) {
21 // we are the loader
22 const value = await fetchFn();
23 if (value) {
24 await redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
25 } else {
26 await redis.set(key, "__MISSING__", "EX", 60);
27 }
28 // release lock safely
29 const script = `
30 if redis.call("GET", KEYS[1]) == ARGV[1] then
31 return redis.call("DEL", KEYS[1])
32 else
33 return 0
34 end
35 `;
36 await redis.eval(script, 1, lockKey, lockToken);
37 return value;
38 } else {
39 // wait for the loader or fallback
40 await new Promise(r => setTimeout(r, 50));
41 const after = await redis.get(key);
42 if (after && after !== "__MISSING__") return JSON.parse(after);
43 // fallback to direct DB fetch if needed
44 return fetchFn();
45 }
46}Example 3 — Simple read-through (conceptual)
- Use a caching library or proxy that reads from cache and automatically populates it from DB on miss, or implement in middleware.
Case studies and real-world patterns
- Session store: store session IDs and small session payloads in Redis with reasonable TTL. Use replication and persistence as needed; beware of huge session sizes.
- Leaderboards: use sorted sets (ZADD, ZRANGE) for ranking, maintain top-k in Redis and periodically persist to DB.
- Rate limiting: token bucket/leaky bucket using INCR and TTL or Redis scripts for atomicity.
- API response caching: cache JSON responses with proper cache invalidation when underlying data changes.
- Search suggestions: precompute or cache frequently requested suggestions and keep in Redis for low-latency reads.
- E-commerce inventory: use Redis for optimistic stock decrements with Lua scripts to ensure atomicity and prevent oversell.
Future directions & trends
- Edge caching and "redis at edge" concepts: while Redis remains centralized, combining CDN edge caches (Fastly, Cloudflare) and Redis for dynamic personalization is common.
- Redis modules expand use-cases: RedisJSON for nested JSON caching, RedisBloom for probabilistic filters, Vector similarity search modules for embedding caches in ML applications.
- Redis on Flash and larger datasets: enabling colder data to be stored on SSDs while keeping hot data in RAM.
- Serverless & managed Redis: serverless apps use managed Redis services; attention to cold start and connection management (use connection pooling and proxies).
Best-practice checklist
- Choose an appropriate caching strategy (cache-aside is the default).
- Set per-key TTL values suitable for staleness tolerance and origin load.
- Configure maxmemory and eviction policy appropriate to your data.
- Implement negative caching for missing items.
- Mitigate stampedes (locks, singleflight, stale-while-revalidate).
- Use Lua scripts for atomic multi-step operations.
- Model data efficiently (hashes, compression, binary serialization).
- Monitor hits/misses, latency, evictions, memory usage.
- Handle cluster slotting (hash tags) when using Redis Cluster.
- Use replication and Sentinel/Cluster for HA and scale reads carefully.
- Implement secure access and tune ACLs/TLS.
- Pre-warm caches on deploy if required.
- Load test both cache and origin to validate behavior under stress.
Pitfalls and anti-patterns
- Caching everything forever without eviction policy → memory exhaustion.
- Ignoring cache stampedes → origin overload during rollouts or expirations.
- Using Redis as a long-term durable store (it’s primarily in-memory — configure persistence carefully).
- Cross-slot operations in Cluster (causes failures).
- Overusing Lua scripts with heavy CPU work — they block the server while running.
- Storing huge values in Redis without considering memory fragmentation or network overhead.
Further reading & tools
- Redis official docs: https://redis.io
- Redis Modules: RedisJSON, RedisBloom, RedisTimeSeries
- Redis Sentinel and Cluster docs
- RedisInsight for monitoring/visualization
- Libraries: redis-py, ioredis, Jedis, Redisson
- Benchmarking: redis-benchmark, memtier_benchmark, YCSB
- “Designing Data-Intensive Applications” (general principles that apply to caching)
This article covers the practical and theoretical aspects backend engineers should know about Redis-based caching strategies. Implementations vary depending on latency and consistency requirements, cost constraints, and workload characteristics — but the core building blocks are consistent: pick a strategy, manage TTLs and evictions thoughtfully, mitigate stampedes, model data efficiently, and instrument/monitor continuously.
If you want, I can:
- Provide a production-ready cache-aside library example in your language of choice (Node, Go, Python, Java).
- Walk through a concrete migration plan for introducing Redis caching into an existing monolith or microservices stack.
- Create benchmarking scripts tuned to your expected workload.