Cache-Aside Pattern Done Right
Cache-aside (also called lazy loading) means the application is responsible for loading data from the database into the cache on read and for invalidating or updating the cache on write. It is the most common caching pattern. This article explains the correct read/write flow, how to avoid common pitfalls (cache penetration, stampede), and when to use TTL and versioned keys.
Overview
In cache-aside, the cache is a side store managed by the application. The database remains the source of truth. On a read miss, the application loads from the database and populates the cache. On a write, the application updates the database first, then invalidates (or updates) the cache so subsequent reads see fresh data.
Read path:
- Check the cache.
- On hit: return the cached value.
- On miss: load from the database, write to the cache (with TTL), return the value.
Write path:
- Update the database.
- Invalidate the cache entry (delete) so the next read will reload from the database. Optionally, update the cache with the new value instead of invalidating.
Why cache-aside: You only cache data that is actually read. The cache holds hot data; cold data is not loaded. You must handle invalidation and consistency yourself, but the pattern is simple and fits most use cases.
Example
Example 1: Basic read path
Javapublic User getUser(long id) { String key = "user:" + id; User u = redis.get(key); if (u != null) return u; u = userRepository.findById(id); if (u != null) { redis.setex(key, 3600, serialize(u)); // TTL 1 hour } return u; }
On a cache miss, we load from the database and populate the cache. The next read will hit the cache. Always set a TTL so stale or wrongly written data eventually expires.
Example 2: Write path — update DB then invalidate
Javapublic void updateUser(User u) { userRepository.save(u); redis.del("user:" + u.getId()); }
Do not update the cache before the database. If the database update fails, you would have inconsistent data in the cache. Invalidate so the next read reloads from the database. Alternatively, you can update the cache with the new value after a successful DB write, but invalidation is simpler and avoids race conditions when multiple writers update the same entity.
Example 3: Cache penetration — missing key
When the entity does not exist in the database, every request will miss the cache and hit the database. An attacker or bug could query for many non-existent IDs and overload the database.
Java// Bad: missing user causes repeated DB lookups User u = userRepository.findById(id); if (u == null) return null; redis.setex(key, 3600, serialize(u)); // Better: cache "absent" for a short TTL User u = userRepository.findById(id); if (u == null) { redis.setex(key, 60, "ABSENT"); // short TTL, e.g. 1 min return null; } redis.setex(key, 3600, serialize(u));
Example 4: Cache stampede — many requests miss at once
When a popular key expires, many requests may miss at once and all hit the database. Use a per-key lock so only one thread loads; others wait or retry the cache.
Javapublic User getUser(long id) { String key = "user:" + id; User u = redis.get(key); if (u != null) return u; String lockKey = "lock:user:" + id; if (redis.setnx(lockKey, "1")) { try { u = userRepository.findById(id); if (u != null) redis.setex(key, 3600, serialize(u)); else redis.setex(key, 60, "ABSENT"); } finally { redis.del(lockKey); } } else { Thread.sleep(50); // brief wait, then retry cache return getUser(id); // retry } return u; }
Consistency Considerations
- Cache is a copy: It can be stale until you invalidate or TTL expires. After a write, invalidate so the next read sees fresh data.
- Race between read and write: Thread A misses, loads from DB. Thread B writes and invalidates. Thread A then writes to cache (old value). To reduce this window, keep the time between DB read and cache write short, or use a version/timestamp and reject stale writes.
- Write-through vs invalidate: Write-through (update cache on write) can be simpler for single-writer cases, but you must ensure the DB write succeeds before updating the cache. Invalidate-on-write is usually easier to reason about for multi-writer scenarios.
TTL and Eviction
- Always set a TTL on cached values. This limits staleness and prevents unbounded growth if invalidation is missed.
- Use a longer TTL for stable data (e.g. 1 hour), shorter for frequently changing data or "absent" placeholders (e.g. 1–5 minutes).
- Add jitter to TTL (e.g. base + random offset) so many keys do not expire at once (avalanche).
| Step | Read (miss) | Write (update) |
|---|---|---|
| 1 | Check cache | Update DB |
| 2 | Load from DB | Invalidate cache |
| 3 | Write to cache + TTL | (optional) Set cache with new value |
Key Rules
- Invalidate (or update) the cache after a successful DB write. Never update the cache before the DB.
- Set a TTL on every cache entry to limit staleness and avoid unbounded growth.
- Reduce stampede with per-key locking or a short "absent" cache for missing keys.
- Prefer invalidate-on-write over update-on-write unless you have a single writer and simple logic.
What's Next
For cache failure modes, see Caching Pitfalls - Penetration / Breakdown / Avalanche. For TTL design, see Expiration Strategy - TTL Design. For distributed locking with Redis, see Distributed Lock with Redis.