system-design · beginner
Stale Cache After Write — When Your Own Update Disappears
Start here
You click Save. The UI says success. You refresh — and the old value is back.
That experience often means:
The database (or primary store) accepted your write, but a cache (or lagging copy) still held the previous value and served it on the next read.
This is a read-your-writes failure from the user’s point of view: I just wrote; I should see my write.
You should care because users interpret this as data loss. Support tickets skyrocket. Engineers waste hours “debugging the frontend” when the real bug is cache invalidation ordering.
What you will learn
- Define the stale-after-write problem in plain English.
- Separate user-visible success from cache coherence.
- Walk the classic cache-aside fill-vs-delete race.
- List other causes: forgotten invalidation, multi-key caches, replica lag.
- Apply fixes: delete-after-commit, versioning, short TTL, read-through primary, sticky sessions for the writer.
- Work a profile-edit example.
- Know what to log and test.
- Avoid “just clear all caches” as the only strategy.
What you should know first
| Topic | Why |
|---|---|
| Caching 101 | Hits, misses, invalidation |
| Caching Strategies | Cache-aside write path |
| Strong vs Eventual Consistency | Read-your-writes as a promise |
Words you need before we begin
| Term | Plain English |
|---|---|
| Read-your-writes | After you successfully write, your later reads see that write. |
| Invalidate | Delete (or mark bad) a cache entry so the next read reloads truth. |
| Populate / fill | Write a value into the cache after a miss. |
| Race | Two concurrent steps interleave in a bad order. |
| Primary | The database copy that accepted the write. |
| Replica lag | Delay before a follower sees the primary’s write. |
| Version / etag | Number or token that increases when data changes. |
| Source of truth | System you trust when copies disagree (usually the DB). |
Simple story: the whiteboard and the photocopy
The whiteboard is the database. The photocopy on the wall is the cache.
- You update the whiteboard: “Meeting at 4pm.”
- You forget to throw away the photocopy that still says 3pm.
- A colleague reads the photocopy and goes at 3pm.
- Someone copies the old 3pm to a new photocopy while you are erasing.
- You delete the old photocopy.
- The new photocopy of 3pm is pinned up after your delete.
- Everyone sees 3pm again.
Where the analogy stops: computers do this thousands of times per second across many machines.
The problem in a request timeline
User steps
PUT /profile→200 OKwith new bio.GET /profileimmediately → old bio.
What may have happened
- App wrote Postgres successfully.
- App failed to
DEL profile:123(bug, timeout, wrong key). - Or app deleted, but another request refilled from a stale read.
- Or GET hit a read replica that was 200 ms behind.
- Or a CDN edge still had the old JSON.
Step-by-step explanation
Step 1 — Decide the promise
For each API, document:
Does the writer need read-your-writes within N milliseconds?
- Account settings: usually yes.
- Global analytics dashboard: often no.
- Public product description: “within a minute” may be OK.
Step 2 — Forgotten invalidation (simplest bug)
Cache-aside write path missing step 2:
UPDATE products SET price=…- ~~
DEL product:{id}~~ ← forgotten - Readers keep hitting old cache until TTL.
Step 3 — The classic race (detailed)
Two requests, same key:
Reader R (miss path)
Writer W (update path)
Bad interleaving:
- R misses cache.
- R reads DB → value OLD (write not committed yet) or timing variants.
- W commits NEW to DB.
- W deletes cache key.
- R sets cache to OLD.
- Future readers see OLD until TTL.
Mitigations:
- Keep TTLs short as a backstop.
- Store version in DB and cache; never fill if version is older.
- Use locks around read-fill for hot keys (costly).
- Prefer delete over set-on-write for complex objects; combine with compare-and-set versions.
- Delay populate slightly (hacky) or re-check DB version before SET.
Step 4 — Multi-key and derived caches
You update user row but cache:
user:1feed:home:1search:autocomplete:jo
Fix: document dependency graph; invalidate all derived keys; or avoid caching hard-to-invalidate derivatives; or use event-driven invalidation with clear ownership.
Step 5 — Replica lag looks like stale cache
Even with perfect Redis logic:
- Write primary.
- Read replica for GET (load balancing).
- Replica not caught up → old value.
- Read-your-writes: route recent writers to primary for a short window.
- Synchronous replication (latency cost).
- Session sticky to primary after write.
- Return written entity in the write response so UI does not immediately need GET.
Step 6 — CDN and browser caches
For public HTTP responses:
Cache-Controlmay allow edges and browsers to keep old JSON/HTML.- After publish, you must purge CDN or use versioned URLs (
app.v3.js).
Step 7 — Practical repair toolkit
| Technique | When it helps |
|---|---|
| Delete-after-commit | Standard cache-aside |
| Version tokens in values | Stop stale fills winning |
| Short TTL safety net | Bounds damage |
| Write response as UI truth | Avoid immediate GET |
| Primary reads after write | Replica lag |
| CDN purge / surrogate keys | Edge staleness |
| Idempotent rebuild jobs | Repair known keys |
Step 8 — Testing the bug on purpose
Add tests or chaos:
- Concurrent read miss + write for same key
- Forced replica delay
- Invalidation timeout injection
Visual mental model
Happy invalidate
flowchart LR
W[Write DB NEW] --> D[Delete cache]
D --> M[Next read misses]
M --> L[Load NEW from DB]
L --> F[Fill cache NEW]
Learning question: What breaks if Delete is skipped?
Caption: Correct cache-aside write path.
Poison fill race
flowchart TB
R[Reader loads OLD] --> S[Writer commits NEW + deletes]
S --> P[Reader populates OLD]
P --> X[Cache poisoned]
Learning question: Which step should check a version before SET?
Caption: Late populate overwrites a correct delete.
Complete worked example: edit display name
Flow
- User submits name “Ada”.
- API updates
users.namein Postgres transaction. - On commit success:
DEL user:9:public. - API returns
{ "name": "Ada" }to the client. - Client updates local state from response (no immediate GET required).
- Other devices may see old name until miss/TTL — acceptable if documented.
- If another tab GETs within milliseconds, single-flight + version reduces races.
Bug that shipped
Invalidation used key user:9 but readers used user:9:public. Silent mismatch → 15-minute TTL staleness.
Lesson: key naming is part of the contract; centralize key builders.
How it works in production
Logging
On write:
- user id, entity version, cache keys deleted, delete success/failure
- cache hit/miss, value version, DB version sample
Metrics
- Invalidation failure rate
- “Write then read mismatch” synthetic canary
- Replica lag
- CDN purge latency
Product messaging
If eventual visibility is expected cross-device, say “It may take a minute to show everywhere” rather than “Saved” with an immediate global promise you cannot keep.
Failure modes
| Cause | Symptom | Fix |
|---|---|---|
| Wrong cache key | Always stale until TTL | Shared key helpers |
| Delete before commit | Rare weirdness | Delete after commit |
| Fill race | Intermittent old values | Versions / locks |
| Replica reads | Old values after write | Primary read window |
| CDN | Old public pages | Purge / versioned URLs |
| Multiple cache layers | Hard to reason | Document layers; purge all |
Trade-offs
| Approach | Freshness for writer | Complexity | Cost |
|---|---|---|---|
| No cache | Perfect | Low | High origin load |
| Invalidate + short TTL | Good | Medium | Occasional misses |
| Versions | Stronger against races | Medium-high | Extra fields |
| Always read primary after write | Strong RYW | Medium | Primary load |
| Write-through update cache | Good if done right | Medium | Dual-write failures |
Compare with related concepts
| Problem | Difference |
|---|---|
| Replica lag | May happen without any app cache |
| Eventual consistency | Broader distributed promise; this lesson focuses on cache coherence after write |
| Stampede | Overload on miss; here the issue is wrongness, not only load |
| Lost update | Two writers overwrite; different bug class |
Common misunderstandings
- “HTTP 200 on write means the next GET cannot be stale.”
- “TTL 24h is fine if we invalidate.”
- “Only Redis causes this.”
- “Returning the write body is cheating.”
- “FLUSHALL in production fixes coherence.”
Check your understanding
A cache fill/invalidation race
A DNS CNAME loop only
Filesystem corruption by definition
Perfect linearizability success
Update the UI from the write response (and invalidate correctly for others)
Never invalidate any keys
Always read a random replica immediately with no lag controls
Disable HTTPS
Practice
Trace this bug report: “I changed my address; the confirmation page shows the new address, but My Account shows the old one for ~2 minutes.”
- List three cache layers that could be involved.
- Write a sequence diagram for a fill race.
- Propose key names and invalidation points.
- Add one automated test idea.
- Choose max acceptable staleness for other devices vs the writer.
Revision summary
- Stale-after-write = truth updated, read path not.
- Common causes: missed invalidation, races, replica lag, CDN.
- Delete-after-commit; unify key names.
- Versions stop poison fills.
- Serve write response to the writer.
- Test concurrency; monitor invalidation failures.
- Document RYW promises per feature.
Glossary
| Term | Definition | Example |
|---|---|---|
| Read-your-writes | Writer sees own updates | Bio after save |
| Invalidation | Drop bad cache entry | DEL user:9 |
| Poison fill | Stale value written after delete | Race sets OLD |
| Version | Monotonic change token | updated_at, row ver |
| Replica lag | Follower behind primary | 150 ms delay |
| Surrogate key | CDN invalidation tag | product-42 |
| Coherence | Copies agree enough for rules | Cache matches DB promise |
| Safety net TTL | Max stale bound | 60s |
Abbreviations and terminology
| Short | Full / note |
|---|---|
| RYW | Read-Your-Writes |
| TTL | Time To Live |
| CDN | Content Delivery Network |
| DB | Database |
| UI | User Interface |
| CAS | Compare-And-Set |
What to learn next
Primary next lesson: Content Delivery Network (CDN)
Revisit cache stampede when origin load is the pain instead of wrongness.
Track: Data, Storage and Messaging
Previous: Read-Through vs. Write-Through Cache — Who Updates the Cache?
Series: Caching
- Caching 101 — Memory Offloading and Latency Reduction
- Caching Strategies — Aside, Through, Behind, and Refresh-Ahead
- Cache Eviction Policies — LRU, LFU, TTL, and Friends
- Distributed Caching — Sharding and High-Availability Clusters
- Cache Stampede — When Expiry Melts the Database
- Stale Cache After Write — When Your Own Update Disappears (this guide)
- Content Delivery Networks (CDN) — Edge Acceleration and Caching
- Bloom Filters — Probabilistic Set Membership at Scale
By Shubham Jain