Cache-aside pattern Basic
Estimated reading time: 7 min read
The problem
Section titled “The problem”A user profile API queries PostgreSQL on every request, even though the same profile is read thousands of times and rarely changes.
When measurements show repeated queries make up most of the database load, a cache cuts the number of trips to the source of truth. A cache does not replace fixing slow queries: check indexes and the query plan first.
Cache-aside (lazy loading) is the pattern where the application, not the cache, owns all the logic: when to read the cache, when to read the DB, when to write back or delete the key. Redis is just a key-value store.
Four caching strategies in one table
Section titled “Four caching strategies in one table”| Cache-aside | Read-through | Write-through | Write-behind | |
|---|---|---|---|---|
| Who loads the cache on a miss | Application | Cache layer | Cache layer (on write) | Cache layer (on write) |
| On write | Write DB, delete key | Same as cache-aside | Write cache and DB synchronously | Write cache, DB updated later |
| Stale data | Yes (race, TTL) | Yes | Less | Cache is newer than DB |
| Data loss risk | No | No | No | Yes, if the cache dies before flushing |
| Complexity | Low, only Redis needed | Needs a cache library/proxy | Higher | Highest |
Cache-aside is the default with plain Redis: it needs nothing beyond the client. The other three strategies need a cache layer that knows how to talk to the database.
When to use it, when not to
Section titled “When to use it, when not to”| Use when | Don’t use when |
|---|---|
| Reads outnumber writes many times over | Write-heavy, data changes almost every time it is read |
| The business accepts update lag bounded by the TTL | Strong consistency is required (balances, access rights) |
| Losing the cache only slows things down; the DB can still cope | The DB cannot cope when the cache is empty |
How it works
Section titled “How it works”GET user:42The application reads key user:42 from Redis before querying the database.
Follow the key user:42:profile from the moment it has no data (TTL 300 seconds):
| Moment | Redis | PostgreSQL | What the application does |
|---|---|---|---|
| Request 1 | No key | An |
Read PostgreSQL, SET An with TTL, return An |
| Request 2 | An |
An |
Return An from Redis, without touching the DB |
| User changes name | An (temporarily) |
Commit Anh |
DEL the key after the commit |
| Request 3 | No key | Anh |
Load Anh into cache, then return it |
The rule to remember: on read, try the copy first; on write, fix the source of truth first, then delete the copy. Deleting the key before the commit is wrong: a request sneaking in between reads the old value from the DB and loads it right back.
Minimal code (illustrative, not run in the repo):
async function getUser(id: number) { const key = `user:${id}:profile`; const hit = await redis.get(key); if (hit !== null) return hit === NULL_MARK ? null : JSON.parse(hit);
const row = await db.findUser(id); const ttl = row ? 300 + Math.floor(Math.random() * 60) : 30; // jitter; short negative cache await redis.set(key, row ? JSON.stringify(row) : NULL_MARK, 'EX', ttl); return row;}
async function updateUser(id: number, patch: Partial<User>) { await db.updateUser(id, patch); // 1. commit the source of truth await redis.del(`user:${id}:profile`); // 2. only then delete the copy}Race: the cache keeps a stale value with no error anywhere
Section titled “Race: the cache keeps a stale value with no error anywhere”The “write DB, then delete key” order still has a gap. A reader that reads the DB before the commit but runs SET after the DEL puts the old value back into the cache.
R1 calls GET, misses the cache, and starts reloading from PostgreSQL.
No operation fails, and the cache keeps An until the TTL expires. Mitigations, from cheap to expensive:
| Approach | Effect | Cost |
|---|---|---|
| Short TTL with jitter | Bounds how long a stale value lives | More misses and DB load |
| Delete again after a short delay (delayed double delete) | Catches most races, not all | Needs a delayed job; the delay must exceed the reader’s DB read time |
Lease/version: only SET if the key has not changed since the miss |
Fully blocks stale sets | Complex; Facebook describes a lease mechanism in the Scaling Memcache paper |
| Read the source of truth for critical flows | Eliminates the problem | No DB load reduction for that flow |
Default choice: a short TTL with jitter, plus reading from the DB for flows that must be exactly right.
Negative caching and TTL
Section titled “Negative caching and TTL”A request for a key that does not exist (user 999999) misses forever, and every time it hits the DB. An attacker or a buggy client can use this to push load onto the DB.
Cache the “not found” result too (like NULL_MARK above) with a short TTL, say 30 seconds, and delete it when the record is created.
Pick the TTL from business requirements; there is no universal number:
- List of provinces, almost never changes: long TTL (hours).
- User profile: a few minutes, plus
DELon write. - Balances, access rights: don’t cache, or cache very briefly and delete on write.
Add jitter to the TTL so keys loaded together don’t expire together (see cache stampede). Keys can still disappear before their TTL because of eviction; see expiration and eviction.
Design keys around how fast the data changes: profile (changes slowly) and current location (changes every second) must live in different keys. If the response depends on permissions or language, put that dimension in the key or don’t cache.
Real-world example
Section titled “Real-world example”Trade-offs
Section titled “Trade-offs”Pros
- Significantly reduces DB load for read-heavy, write-light data
- Only caches data that is actually read (lazy)
- Losing a cache key loses no data; the DB remains the source of truth
Cons
- The first read after every miss is always slower
- There is a gap between the DB write and the cache delete, plus the stale set race
- Adds a point of failure (Redis) and a new class of bug: cache drifting from the DB
Production checklist
Section titled “Production checklist”- Settle “how stale this data may be” for each key type, then choose the TTL.
- Always add jitter to TTLs; keys loaded in bulk must not expire in the same second.
- Write the DB first,
DELsecond; handle a failedDEL(retry, or push it through an outbox). - Add a negative cache with a short TTL for nonexistent keys.
- When Redis fails: short timeouts, a circuit breaker, a concurrency limit on DB calls. Fallback does not mean the DB can take all the traffic.
- Hot keys: coalesce reload requests (stampede).
- Metrics: hit rate, miss-path latency, DB queries by fingerprint,
evicted_keys. - Flows that must be exactly right read straight from the source of truth.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) Why is deleting the key before the commit wrong? (2) Redraw the sequence that leaves An in the cache after the DB already holds Anh. (3) When is write-behind more dangerous than cache-aside?