Ch. 19 · System Design

System Design Cache-Aside and Stale Data

Cache-aside read and write paths, why you update the database before deleting the key, and the race that still leaves stale data until TTL.

~7 min readbeginnerupdated Oct 11, 2026

A product page reads the same row from PostgreSQL thousands of times between edits. Each read costs a few milliseconds and a database connection. Put a Redis cache in front, and most of those reads become sub-millisecond lookups that never touch the database.

Cache-aside (also called lazy loading) is the usual way to do that. The application checks the cache first, and on a miss it loads from the database and writes the result into the cache with an expiry. It’s simple, which is why it’s the default answer in interviews. The follow-up questions are about writes: how does the cache find out the data changed, and what happens when a read and a write overlap?

The read path

Here’s the read side with node-redis. It’s illustrative (the database call is a placeholder), but the Redis calls are the real API:

async function getProduct(id) {
  const key = `product:${id}`;
  const cached = await redis.get(key);
  if (cached !== null) return JSON.parse(cached);           // hit

  const row = await db.query('SELECT * FROM products WHERE id = $1', [id]);  // miss: go to the authority
  await redis.set(key, JSON.stringify(row), { EX: 300 });   // copy it, with a 5-minute expiry
  return row;
}
JavaScript

Three properties of this code are worth saying out loud in an interview.

The database is the authority. The cache holds a copy that you can delete at any time without losing data. If Redis restarts empty, the next reads repopulate it. That’s the big advantage of cache-aside over write-behind caching, where the cache briefly holds the only copy of a write.

Every entry has an expiry. The EX 300 means that even if every other mechanism fails, no copy can outlive five minutes. Keep that in mind; it comes back later.

The cache only holds what people read. Products nobody views never take up memory.

The cost shows up as load on the database when the cache isn’t helping. Say the product service handles 20,000 reads per second with a 95% hit ratio. The database sees 1,000 queries per second. If the cache cluster goes down, the database suddenly sees 20,000, which it was never sized for. A cache that’s needed for the database to survive isn’t optional infrastructure anymore, and your design should say what happens when it’s gone (shed load, serve a degraded page, or size the database for some of that traffic).

Keeping the copy honest on writes

When an admin changes the price from 39 to 41, the cached copy is wrong. There are three obvious ways to fix it, and only one holds up.

Delete the key, then update the database. This leaves a gap. Between the delete and the commit, a reader misses, loads the old row (the update hasn’t committed yet), and writes 39 back into the cache. The fresh delete achieved nothing, and the old price now sits there until the next write or the TTL.

Update the database, then set the cache to the new value. Better, but two writers can reorder. Writer A commits 40 and writer B commits 41, then B’s cache SET arrives before A’s. The database says 41 and the cache says 40. It also means the write path computes cache values, which gets messy when the cached object joins several tables.

Update the database, then delete the key. The next reader misses and loads whatever is committed. Deletes are idempotent and can’t be reordered into a wrong value: whichever delete lands last, the key is gone. This is the standard choice.

A frequent interview mistake is treating the database update and the cache delete as if they happen together. They’re two network calls to two systems. Your process can crash between them, leaving the old value cached until it expires. The TTL covers that case too.

The race that survives the right order

Even with update-then-delete, one interleaving still leaves stale data. It needs a reader that misses before the write and is slow to populate. Here’s that sequence replayed with a fake cache and database in Node, using a 300-second TTL:

t=     0ms  reader A GET cache -> miss
t=     4ms  reader A SELECT -> price 39   (then A stalls in a GC pause)
t=    24ms  writer B UPDATE price = 41, COMMIT
t=    26ms  writer B DEL product:42
t=   306ms  reader A SET product:42 price 39 EX 300
t=  1306ms  reader C GET -> price 39  (database says 41)
t= 60306ms  reader C GET -> price 39  (database says 41)
t=299306ms  reader C GET -> price 39  (database says 41)
t=300307ms  reader C GET -> miss, reload price 41
Text

Reader A read the old row, then paused. Writer B committed and deleted the key while A was paused. A woke up and cached the value it read before the commit. For the next five minutes every reader sees 39, and nothing in the write path will fix it, because the write path already ran. Only the TTL does.

This race is rare because it needs a miss and a write to overlap with a stall in between. On a product edited twice a day it might never happen. On a catalog with millions of products and constant edits, it happens somewhere every day.

How often each strategy serves stale data

To compare the strategies with numbers, this simulation runs one hot key for an hour: 200 reads per second, 2 writes per second (think of a stock counter), a 60-second TTL, and cache hops that take 2 ms but stall for 300 ms 1% of the time:

strategy                  stale reads   longest stale stretch
delete, then update DB      29.02%      4.247 s
update DB, then set          1.18%      1.361 s
update DB, then delete       1.32%      1.561 s
update DB, delete twice      1.19%      0.841 s
Text

Deleting before updating is in a different league: more than a quarter of reads were stale, because almost every write lets a reader cache the old row. The two update-first strategies are close on this busy key. Setting looks slightly better here because it refills the cache with the new value instead of leaving a miss, but it carries the reordering problem and makes writers responsible for building cache entries. The last row adds a second delete one second after the first (sometimes called delayed double delete), which clears most values a slow reader put back and cut the longest stale stretch nearly in half.

The longest stretches are short only because this key is written twice a second: the next write clears any stuck value. With rare writes, as in the replay above, the stuck value lasts until the TTL. That’s the main lesson. On a rarely written key, the TTL is the actual upper bound on staleness, so choose it on purpose.

Choosing TTLs from acceptable staleness

Ask the product question for each piece of data: how wrong may this be, and for how long?

  • A product description can be an hour old. Use a long TTL and rely on the delete for prompt updates.
  • A displayed price might tolerate a minute if checkout re-reads the price from the database. Never charge a customer based on a cached price.
  • Available stock for the last few items may not belong in a cache at all, or only with a few seconds of TTL.

When the staleness budget is near zero but you still need a cache, move invalidation out of application code. Stream the database’s change log (change data capture, for example Debezium reading PostgreSQL’s write-ahead log) and delete keys from that stream. It catches every write, including ones made by scripts and other services that never knew about the cache. Versioned values help too: if the cached entry carries the row’s version, a reader can refuse to overwrite a newer version with an older one.

Interview practice: the name that won’t update

“After users change their display name, they keep seeing the old name in the header for up to ten minutes. Profiles are cached with cache-aside and a 10-minute TTL. What’s going on, and how would you fix it?”

Answer

Ten minutes is exactly the TTL, which tells me the write path isn’t invalidating, or isn’t invalidating reliably, and expiry is the only thing cleaning up. I’d check three things. First, does the profile update delete profile:{id} at all, and does it happen after the commit rather than before? Deleting first lets a concurrent reader cache the old name straight away. Second, are there other writers (an admin tool, a batch import) that update the table without touching the cache? Third, is the header reading a different key, such as a session object that copied the name at login?

The fix is to update the database, then delete the key, in every code path that writes profiles, ideally driven by change data capture so new writers can’t forget. For read-your-own-writes, the user who just made the change can read their profile from the database for a few seconds after editing, which hides the remaining race from the one person most likely to notice. I’d keep a TTL as the backstop, perhaps shorter, and test by forcing a slow reader to overlap a write to confirm the stale window is bounded.

Further reading

More in System Design

read ✓System Design · hard

System Design: Presence Tracking

Build online indicators for 10 million users: heartbeats with TTLs, why the TTL must cover lost beats, and fan-out only to who's watching.

~6 min readread →
read ✓System Design · mid

System Design: URL Shortener

Size a URL shortener from 100M links a month, pick between counter-based and random codes, and keep a 4,000 req/s redirect path cheap.

~6 min readread →
esc