Skip to content

Cache-aside and the read and write paths

The database is the truth; the cache is whatever was written to it last. Delete after the commit, and the next read restores the truth.

Predict first

Before you read: predict

ExamHall serves GET /exams/:id/config with cache-aside on one shared Redis (TTL 1 h). On an edit, the handler updates Postgres, then SETs the new config in Redis. ECON-201 ends at 10:30 and is cached. Two admins edit it within a second: admin A's request commits 10:45, then admin B's commits 11:00. B's handler runs its SET first; A's SET lands after it.

What do candidates read from then on?

Pick one

The 30-second version

Cache-aside reads the cache and, on a miss, loads the row from the database and stores it. The cache keeps whatever was written to it last, not what the database committed last. So on a write, commit, then delete the key: deletes in any order let the next read load the committed row. Setting values can land out of order.

The database is the truth; the cache is whatever was written to it last. Delete after the commit, and the next read restores the truth.

How it works

Step 1 of 11

Read path. GET the key. On a hit, return the copy. On a miss, SELECT the row, SET it with a TTL, and return it.
In the kitchen
  • a gap on the shelf: print from the price file, clip the label on → a miss: load the row, SET it (step 1)

Analogy: A supermarket's shelf-edge price labels

Head office keeps the price file. Each shelf carries one label per product. An assistant who finds a gap prints a label from the price file and clips it on.

In the supermarket In the system
a gap on the shelf: print from the price file, clip the label on a miss: load the row, SET it (step 1)
the price file the database, the truth (step 2)
two price changes; two assistants carry new labels and arrive in the wrong order crossed SETs (step 3)
change the file, then pull the label; the next assistant prints the current price delete after the commit (step 5)
pull the label, then change the file; someone reprints the old price in between delete before the commit (step 6)
the till takes the new price now; head office hears at the evening upload write-behind (step 10)

In real terms: the cache is a copy anyone may overwrite; deleting after the commit hands the next reader the truth, and the TTL caps what slips through.

Breaks down when: a shopper sees a label that disagrees with the till and complains, so a wrong label gets noticed. Wrong inference: "a stale entry gets noticed and fixed soon." A cache never compares its copy with the database; only a delete, a write or the TTL replaces it.

Contrasting case: no labels; every shopper asks at the till. Nobody sees a wrong price, and everyone queues. That is no cache: every read goes to the database.

Examples, run for real

4.1 ExamHall: two edits, and the first one won

EXH-733: two admins changed ECON-201's duration within a second, and for the next hour candidates got the first edit. The handler SET the config after each commit; MR !1204 deletes the key instead. Same forced interleaving:

4.1 two admins edit ECON-201 duration: A commits 105, B commits 120; B's cache step runs, then A's
  before (SET after commit)    database 120 · cache 105 · next 3 reads 105 105 105 · database loads 0
  after MR (DEL after commit)  database 120 · cache (empty) · next 3 reads 120 120 120 · database loads 1

Before: the cache kept the last SET, which carried the older commit (steps 2–3). After: two deletes in either order leave no key, and one miss loads the committed row (step 5).

4.2 Ticketing: when the delete runs

An event moves from 19:30 to 20:00 while a fan's read misses; gates force each order.

4.2 an event moves from 19:30 to 20:00 while a fan's read misses (TTL 60 s)
  DEL, then UPDATE; the read runs between them        fan stored 19:30 · database 20:00 · next reads 19:30 19:30 · stored copy expires within 60 s
  UPDATE, then DEL; the read runs after the DEL       fan stored 20:00 · database 20:00 · next reads 20:00 20:00 · stored copy expires within 60 s
  UPDATE, then DEL; the read loaded before the UPDATE and stored after the DEL   fan stored 19:30 · database 20:00 · next reads 19:30 19:30 · stored copy expires within 60 s

Deleting first left a gap the read filled with the old row (step 6). Deleting after the commit fixed it; the third line is the race that remains, capped by the TTL (steps 7, 11).

4.3 Where the write lands

Five answer saves through the pack's cache layer; the write-behind run ends by killing Redis.

4.3 a candidate saves 5 answers through a cache layer
  read-through: the app called layer.get 3 times · loader ran 1 · app code database calls 0
  write-through: 5 saves acknowledged · in Postgres 5 · reads right after each save from the cache 5/5
  write-behind: 5 saves acknowledged · flushed after save 3 · Redis killed (SIGKILL) · in Postgres 3 of 5 acknowledged

Read-through moved the loader out of the app (step 8). Write-through waited for Postgres and left the cache warm (step 9). Write-behind acknowledged two saves Postgres never got (step 10).

Picture it

a grey-haired clerk in a teal jumper stands in front of a tall shelf of blue, teal and white binders and drops a pale folder into an open wooden drawer beside a desk: a miss in the near drawer filled from the large archive.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

A box office uses write-behind: each seat purchase is written to Redis and queued, and a flusher writes the queue to Postgres after every 50th queued purchase. 120 purchases are acknowledged, then the Redis node crashes and its memory is gone.

How many purchases are in Postgres?

Pick one

Question 2 of 5

A price change runs in one transaction:

The insert hits a duplicate key and the transaction rolls back, yet shoppers see 19.99. Shoppers read the key all the time, including while the transaction is open.

Which change stops the cache showing a price the database rejected?

Code for this question
await db.transaction(async (tx) => {  const [row] = (await tx.query(`UPDATE products SET price = 19.99 WHERE id = 'P9' RETURNING price`)).rows;  await cache.set('product:P9', JSON.stringify(row), { expiration: { type: 'EX', value: 3600 } });  await tx.query(`INSERT INTO price_history VALUES ('P9', '2026-10-06', 19.99)`);   // fails: that day's row exists});
Pick one

Question 3 of 5

A player reports each viewer's position every 2 s: 20 viewers, 600 updates a minute. Losing up to a minute of a position in a crash is acceptable. The database can take at most 40 writes a minute.

Which write path meets both limits?

Pick one

Question 4 of 5

A repricing job moves all 20 price tiers of an event. Pages read tiers with cache-aside (TTL 1 h). The job:

Tier 8 was not cached when a run started, and pages read it during the run. Afterwards tier 8 keeps its old price for an hour; the other 19 are right.

What went wrong?

Code for this question
async function reprice(eventId, tiers) {  for (const t of tiers) {    await db.query('UPDATE tiers SET price = $2 WHERE id = $1', [t.id, t.price]);    await cache.del(`tier:${eventId}:${t.id}`);  }}
Pick one

Question 5 of 5

A chat app's profile service has three goals: the app code never queries the database for profiles; the read right after a profile update is a cache hit with the new name; and an update is acknowledged only once Postgres holds it.

Which design meets all three?

Pick one

Go deeper

Pro tips
  1. Write the version with the value, and refuse older ones. Store {v, version} and have the fill or the update SET only when its version is newer, in one atomic step on the server. When: update-on-write is required (for example, the cache must never miss after a write) and writers can cross (steps 3–4). Status: DOC. The memcache paper describes the same problem as a "stale set" and fixes it with leases, tokens the cache checks before it accepts a set. Not run here; the atomic compare belongs to nosql-redis-data-structures. This is a version on the value, checked at SET; versioned keys (a new key per version) belong to cache-invalidation-strategies.
  2. Log the order of the three events per key when you debug staleness: the commit, the cache delete or SET, and every fill. When: a report says "the old value came back after the edit" (steps 4, 6, 7).
Source
  • Microsoft Azure Architecture Center, Cache-Aside pattern: https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside · fetched 2026-09-27. Read path steps 1–3; on update the application "writes the change to the data store and then invalidates the corresponding item in the cache"; "Update the data store before removing the item from the cache", because otherwise a fetch in between retrieves "the outdated item" and adds it back. It also says the pattern "doesn't guarantee consistency" (step 7). Valid as of 2026-09-27.
  • Microsoft Azure Architecture Center, Caching guidance, "Keep the cache consistent on writes": https://learn.microsoft.com/en-us/azure/architecture/best-practices/caching · fetched 2026-09-27. Invalidate on write: a reader "can experience a cache miss or briefly see stale data"; write-through: "return the write response only after both updates succeed", "at the cost of higher write latency" (steps 5, 9). Valid as of 2026-09-27.
  • AWS whitepaper, Database Caching Strategies Using Redis, "Caching patterns": https://docs.aws.amazon.com/whitepapers/latest/database-caching-strategies-using-redis/caching-patterns.html · fetched 2026-09-27 (AWS marks it "for historical reference only"). Cache-aside (lazy loading) read path; its "write-through" is the application updating the database and "Immediately afterward" the cache, which is this lesson's update-on-write. Valid as of 2026-09-27.
  • Oracle Coherence 14.1.2, Caching Data Sources: https://docs.oracle.com/en/middleware/fusion-middleware/coherence/14.1.2/develop-applications/caching-data-sources.html · fetched 2026-09-27. Read-through: on a miss the cache "delegates to the CacheStore"; write-through: the put does not return until the data source has stored it; write-behind: entries are "asynchronously written to the data source after a configured delay", repeats are "coalesced", and write-behind "effectively makes the cache the system-of-record" until the queue is written (steps 8–10; the pack's cacheLayer follows these semantics). Valid as of 2026-09-27.

Checked against the tools and docs as of 2026-09-27.