Cache invalidation
How every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.
The problem: a price that will not change
A shop runs a sale. At 9:00 the merchandising team drops the price of a pair of headphones from $129 to $99 and sends an email to 400,000 people. The database row changes in a few milliseconds. The email goes out at 9:01.
By 9:05 the support inbox is filling up. Some customers see $99, some see $129 on the product page but $99 at checkout, and one sees different prices in two tabs of the same browser. Nothing is broken in the usual sense: every system did exactly what it was told.
The trouble is that the price is not stored in one place. To make the site fast, the team cached it in many places, and each copy has its own idea of when it is out of date. Changing the database changes only the first copy.
One value, many copies
Follow the price from the database to the screen and count the copies. A typical web stack keeps one in each of these layers:
- The database. The source of truth: when people say "the price changed", they mean this row.
- A server cache. Redis or Memcached in front of the database, holding a copy keyed like
product:42. - The CDN. A content delivery network: servers near users that keep copies of the product page and the
/api/products/42response until they expire or you purge them (ask the CDN to delete them). - The service worker. A progressive web app may keep its own copy in the Cache API.
- The browser HTTP cache. The browser's disk cache, steered by
Cache-Control. - The client data cache. TanStack Query, SWR, Apollo, or a Redux store, holding the product in this tab's memory.
- The screen. The DOM showing
$129is a copy too; it changes only when a component renders again.
Each layer saves a query, a round trip, or a render, and each adds a place where the old price can hide. Cache invalidation is the work of making sure that, after the source changes, every copy is either updated, thrown away, or allowed to live only for a time you chose on purpose.
What stale data costs
A stale view count harms nobody. The cost depends on what the data is and who acts on it:
- Money and trust. A wrong price, stock level, or seat map means angry customers and refunds.
- Your own action. A user renames a document and the sidebar still shows the old name, so they assume the save failed. This is the most common case in frontend work.
- Security. A user removed from a team still sees its data in a cached response. Permission changes are invalidation problems.
- Load. The opposite failure: a team turns caching off and the database falls over at the next spike.
So the goal is never "no stale data". It is: for each kind of data, decide how stale it may be, then pick the cheapest mechanism in each layer that stays within that bound.
Check your understanding · Exercise 1
The price row in the database changes. Which copies change at the same moment, with no extra work? Pick one.
The mental model: three ways a copy can die
A restaurant changes a price. The menus on the tables and the PDF on its website are now wrong. The owner has three ways to deal with it, and every cache uses the same three.
- Reprint every menu each morning, changed or not. Wrong menus last at most a day. This is a TTL, a time to live: a copy is trusted for a fixed time, then dropped or checked.
- Swap the menus the moment a price changes. Wrong menus last minutes, but someone must do the walking and not miss a table. This is event-driven invalidation: whoever changes the data tells the caches.
- Print "Menu 15" on the new ones and ignore anything older. Old menus are simply never used again. This is a versioned key: new content gets a new name.
Every technique in this lesson is one of these three, or a mix. Tags aim events ("swap every dessert menu"). Hashed file names are versioned keys. stale-while-revalidate is a TTL with a grace period.
stale-while-revalidate, which serves Stale while it refetches.Staleness is a bound you choose
Each approach gives you a different guarantee about the stale window, the time between the source changing and the last user seeing the old value.
| Question | TTL | Event | Versioned key |
|---|---|---|---|
| Worst stale window | The TTL (or the sum of TTLs across layers) | Time for the message to arrive, if it arrives | Time until the reader learns the new key |
| Who must know what changed | Nobody | The writer, and it must know every affected key | Whoever hands out the key |
| Extra load | One refetch per copy per TTL, changed or not | Refetches only when data changes | None for old copies; new copies are misses |
| What breaks it | TTL too long for the data | A lost message, a missed key, a race | A cached pointer to the old key |
| Best for | Data that may be a little old | Data that must change fast, rarely | Content that never changes once named |
Versioning does not remove invalidation; it moves it. app.3f9a1c.js can be cached forever, but the HTML that names it must not be. The problem shrinks to one small pointer, a very good trade.
"Only two hard things"
You will hear this line in almost every talk about caching: "There are only two hard things in Computer Science: cache invalidation and naming things." It is usually credited to Phil Karlton, an engineer at Netscape in the 1990s.
No original written source is known. The line seems to have spread by word of mouth, and Tim Bray and Martin Fowler are among those who wrote it down and credited Karlton. Say "attributed to Phil Karlton" and you are on safe ground. A later joke adds a third hard thing, off-by-one errors.
No single step is difficult; deleting a Redis key is one line. It is hard because three facts pile up:
- You must know every copy. One value feeds the product page, the category list, search results, the home page. Miss one and it stays stale.
- Some copies are out of reach. The server can purge its CDN, but not a browser's HTTP cache or a tab's memory.
- Reads and writes overlap. A slow read that started before the write can put the old value back after you deleted it.
And naming things? A good cache key is a name that changes exactly when the content does, so versioned keys are where the two hard things meet.
Check your understanding · Exercise 2
Which of these are versioned keys at heart? Select all that apply.
TTLs: let copies expire on their own
A TTL is the simplest plan: every copy carries a lifetime, and when it is over the copy is dropped or checked again. Nobody has to know what changed. TTLs are everywhere: Cache-Control: max-age=60 in HTTP, EX 300 on a Redis SET, staleTime: 30_000 in TanStack Query, maxAge in a service worker library.
The price is easy to reason about: after a change, users can see the old value for up to one TTL, and every copy is refetched once per TTL even if nothing changed.
Pick the TTL from the data, not from the layer
Ask for each kind of data: if a user sees a value N seconds old, what goes wrong? Some rough bands:
| Data | Tolerable staleness | Typical TTL |
|---|---|---|
| List of countries, feature copy, help pages | Days | 1 day, plus a versioned key on deploy |
| Product description, user avatar | Minutes to hours | 5 to 60 minutes |
| Price, stock count on a listing page | Seconds to a minute, if checkout rechecks | 30 to 60 seconds, plus events |
| Stock and price at checkout, a bank balance | None | Do not cache; read the source |
| A user's own data right after they edit it | None for that user | Invalidate on mutation (chapter 6) |
The checkout row matters: cache the listing for a minute, but recheck price and stock on the server when the order is placed. The stale window then costs a moment of confusion, not money.
TTLs in different layers add up
A trap that catches experienced teams: suppose each layer has a modest TTL. Redis keeps product:42 for 5 minutes, the CDN keeps the API response for 10 minutes, and the client library treats data as fresh for 1 minute. How stale can the price on screen be?
Not 10 minutes, but up to 16. The CDN can fetch at 4:59 and get a value Redis has held for almost 5 minutes. The API builds a new response from it, so the CDN keeps it 10 more minutes. The client fetches at the end of that and keeps it 1 more minute.
HTTP caches in one chain are smarter about this. A CDN that serves a stored response adds an Age header saying how many seconds the copy has spent in caches. The browser counts that age against max-age, so a browser and a CDN share one clock instead of adding two.
But Redis, a service worker's Cache API, and a client data library do not read or send Age. Each one starts its own clock when it gets the data, so their TTLs add.
So to answer "how stale can this be?", add up the lifetimes of every layer that starts its own clock.
Grace periods and jitter
Two refinements make TTLs kinder to users and servers.
stale-while-revalidate.Cache-Control: max-age=60, stale-while-revalidate=300means: fresh for 60 seconds; for the next 300 seconds serve the old copy at once and refresh it in the background. Users never wait for a refetch. But be honest about the cost: the stale window is now up to 360 seconds, not 60. The same idea exists in client libraries, where cached data is shown immediately while a refetch runs.- Jitter. 10,000 keys written at 9:00 with a 300-second TTL all expire at 9:05 together. A random 0 to 30 extra seconds each spreads the refetches out. One very hot key can still cause a stampede (many requests miss at once and all hit the database); let one request refill while the others wait.
Check your understanding · Exercise 3
Redis holds a product for 120 s. The API responds with Cache-Control: s-maxage=300 (a lifetime for shared caches only) and the CDN stores it. A React app keeps the data fresh for 60 s with staleTime. Roughly how long after a price change can a user still see the old price, in the worst case? Pick one.
Event-driven invalidation: tell the caches
With event-driven invalidation, whoever changes the data announces it, and each cache drops or replaces its copy. The stale window shrinks to the time the message takes, often under a second. The price is plumbing: you must know which copies to touch, and reach them.
Delete the copy, or write the new value?
On the server, the usual setup is cache-aside: read Redis first; on a miss, read the database and store the result. On a change, you have two choices.
- Delete it (
DEL product:42). The next read misses and loads the current row, even if two writes landed close together. This is the default. - Write the new value (
SET product:42 ...). No miss afterwards, but two writers that finish in the wrong order can leave the older value for a whole TTL. Use it only when writes are serialized or values carry versions.
The browser has the same choice (mark a query stale, or write the response into it); chapter 6 covers it.
Reaching every layer
An event is only as good as its reach. Here is how a single price change travels to each layer in a well-built stack:
- The admin service commits the new price to the database.
- It deletes
product:42in Redis. In larger systems a separate process reads the database's change log and sends the deletes, so no code path can forget to. - It calls the CDN's purge API for the product (by URL, or better, by tag; see chapter 5). On major CDNs a purge usually takes effect worldwide within about a second.
- It publishes a
product.updatedevent with the id42to a message bus. A realtime service forwards it to connected browsers over a WebSocket or server-sent events. - In each open tab, a small handler receives the event and calls
queryClient.invalidateQueries({ queryKey: ['product', 42] }). If the product is on screen, it refetches through the now-purged CDN and gets $99.
Two layers are missing on purpose. The server cannot purge an entry from a browser's HTTP cache: a copy stored with max-age=600 stays fresh for 600 seconds whatever you purge. It reaches a service worker only through the page or a push message. So for data that reacts to events, keep the browser lifetime at no-cache (revalidate every time) and let the CDN, which you can purge, hold the long lifetime.
Events get lost, so keep a TTL
Messages fail in ordinary ways: a laptop sleeps through the event, a socket reconnects a second too late, a purge call times out, a new page is missing from the purge list. Each leaves a copy stale with nothing to end it.
So production systems pair events with a TTL: the event is the fast path, the TTL bounds how long a missed event can hurt. On the client, the safety nets are "refetch when the tab becomes visible again" and "refetch everything on screen after a socket reconnects", which TanStack Query and SWR offer as refetchOnWindowFocus and refetchOnReconnect (SWR names them revalidateOnFocus and revalidateOnReconnect).
Check your understanding · Exercise 4
A price change purges the CDN and sends a WebSocket event to open tabs. Which users can still see the old price for a long time? Select all that apply.
Invalidating after a mutation
The most common invalidation in frontend work has nothing to do with CDNs. The user just changed something, so this client knows exactly what changed. Users expect read your own writes: after I save, everything I look at shows my change.
A server-state library such as TanStack Query, SWR, RTK Query, or Apollo caches query results by key. After a mutation you tell it what is now wrong, in one of four ways.
The user renamed project 7. The code runs invalidateQueries({ queryKey: ['projects', 7], exact: true }).
2 queries still show the old name. 1 request goes out now. Outlined keys are on screen.
Decision After renaming project 7, how do you aim the invalidation?
What we pick: Combine them: write the response into the detail key you already have, then invalidate a prefix (or tag) for the lists. Design query keys as a hierarchy, entity type first, so a prefix covers every view of that type.
const rename = useMutation({
mutationFn: (input: { id: number; name: string }) =>
api.renameProject(input),
onSuccess: (project) => {
// what we know: the server sent the new project
queryClient.setQueryData(["projects", project.id], project);
// what we don't: every list, count, and search that shows it
return queryClient.invalidateQueries({
queryKey: ["projects", "list"],
});
},
});
What "invalidate" actually does
In TanStack Query, invalidating marks matching queries stale and refetches only the active ones, those a mounted component is using (refetchType: 'active', the default). The rest refetch when a component next needs them; 'all' refetches them too, 'none' only marks. So a broad prefix is cheaper than it looks: you pay for the views on screen.
The refetch must not hit another stale cache
A bug that confuses people for hours: the mutation succeeds, the query refetches, and the old name comes back. The refetch was answered by a lower layer: the browser's HTTP cache (the GET had max-age=60) or an unpurged CDN.
- Serve user-editable API data with
Cache-Control: no-cacheorprivate, max-age=0, so the browser always revalidates. A304is cheap; a stale read after a save is not. - If the CDN caches the response, the write must purge it before the client refetches, which means purge first, then return the mutation's response.
- When in doubt, have the refetch bypass the browser cache with
fetch(url, { cache: 'no-cache' }).
Two related lessons go deeper. Normalization stores each entity once by id, so one write updates every view; lists still need invalidation when items are added or removed. Optimistic updates write the expected result early and still end with an invalidation.
Check your understanding · Exercise 6
Query keys in a TanStack Query app: ['projects', 7], ['projects', 'list', { page: 1 }], ['projects', 'list', { page: 2 }], ['dashboard'] (which also shows project names). The user renames project 7 and the code calls invalidateQueries({ queryKey: ['projects'] }). Which statements are true? Select all that apply.
Order across layers, and the stale refill race
Two things can still put the old price back: invalidating layers in the wrong order, and a slow read that finishes after your invalidation. Both come from one fact: each cache refills from the layer below it.
Invalidate from the source outwards
The layers form a chain: database, server cache, CDN, service worker, client cache, screen. If you clear an outer layer while an inner one still holds the old value, the next miss copies the old value outwards for a whole new TTL.
The safe order is the order the data flows:
- Commit to the database and wait for the commit. Purging before the commit lets a reader load the old row. If the origin reads from database replicas, wait until the change is visible there too.
- Clear the server cache (
DEL product:42). - Purge the CDN by tag, and wait for the purge API to confirm.
- Notify clients (WebSocket or server-sent events), which invalidate their in-memory caches and, if needed, ask the service worker to drop its copy.
- Refetch on screen. The refetch now passes through empty layers all the way to fresh data.
Across services, keep the order: publish product.updated after the commit; a cache service deletes Redis, then purges the CDN, and only then notifies clients.
The race: a stale read refills the cache
Even in the right order, a reader that started before the write can undo your work. Step through the classic cache-aside race:
Step 1: Reader A asks Redis for product:42 and misses.
- Reader A misses Redis and reads the database: $129.
- Before A writes to the cache, it pauses: a slow network hop, a garbage collection pause, a busy thread.
- The writer commits $99 and deletes
product:42from Redis. The cache is empty, which looks correct. - Reader A wakes up and stores $129 in Redis with a 5-minute TTL.
- For the next 5 minutes every reader gets $129. Nothing will delete it, because the delete already happened.
The window is small, but on a key read thousands of times a second it happens often. The CDN has the same race: an origin fetch that started before the purge can store an old response after it.
Ways to close it
- A TTL as a backstop. It does not prevent the race; it caps the damage. Always have one.
- Delayed double delete. Delete the key at once and again a second or so later, longer than a slow read. Cheap and effective, but not airtight: a read slower than the delay still wins.
- Leases. On a miss, the cache hands the reader a token. A delete cancels any outstanding token. When the reader tries to store its value, the cache refuses it because the token is no longer valid. Facebook described this in its 2013 paper on scaling memcache; it also stops many readers from refilling the same key at once.
- Versioned values. Store the row's version with the value (
{ price: 99, version: 18 }) and only write if the version is newer than what is there, using a compare-and-set or a small Redis script. An old read with version 17 can then never overwrite version 18.
The client has the same race in miniature. TanStack Query handles it: by default (cancelRefetch: true), invalidateQueries cancels a fetch already running for a matching query and starts a fresh one. An answer from the old fetch is then ignored, so it cannot overwrite the new one.
Check your understanding · Exercise 7
A team purges the CDN first, then deletes the Redis key, then commits the database change. Which problems can this cause? Select all that apply.
Stale UI across views and tabs
Every layer below can be fresh and the screen still wrong, because the DOM and any component state filled from data are copies too. This chapter covers two views in one tab that disagree, and two tabs that disagree.
Two views, one tab
The user adds an item on the product page and the header still says "Cart (2)". Almost always, the two views hold separate copies:
- Each component fetches on its own with
useEffectanduseState, so only the one that made the change knows. Fix: read server data through one shared cache, so both views subscribe to the same entry. - State copied from props once.
useState(project.name)copies on the first render and ignores later changes. Fix: reset the draft when the source changes, for example with akeyof the project's id and version. - One entity under two query keys. Updating
['projects', 7]leaves['projects', 'list']old. Fix: invalidate both, or normalize.
Two tabs, two caches
Each tab has its own memory, so a query cache in tab A has no idea tab B exists. Tabs of one origin share the HTTP cache, the service worker, IndexedDB, localStorage, and cookies, but not in-memory caches or the DOM. Tab B shows the old cart until something makes it refetch.
BroadcastChannel message, or a server event.Three ways to wake the other tabs, cheapest first:
- Refetch when the tab becomes visible. If tab B refetches stale queries on focus (
refetchOnWindowFocus,revalidateOnFocusin SWR, both on by default), it is correct by the time anyone looks. This alone fixes most cases. - Tell the other tabs. A
BroadcastChanneldelivers a message to every other same-origin tab, window, and worker that opened a channel with the same name. Send the key that changed, and each tab invalidates it. - Let the server tell each tab. If every tab has a WebSocket or server-sent events stream, the server's
product.updatedevent reaches them all, which also covers the same user on another device.
const channel = new BroadcastChannel("query-invalidation");
// after a successful mutation in this tab
function invalidateEverywhere(queryKey: readonly unknown[]) {
queryClient.invalidateQueries({ queryKey });
channel.postMessage({ type: "invalidate", queryKey });
}
// in every tab: other tabs' messages arrive here
channel.onmessage = (event) => {
if (event.data?.type === "invalidate") {
queryClient.invalidateQueries({ queryKey: event.data.queryKey });
}
};
A BroadcastChannel does not deliver a message back to the object that sent it, so a tab does not invalidate itself twice. It reaches only tabs that are open; a tab opened later never sees it, so focus refetching stays as the safety net. Every current major browser supports it; Safari was last, in 2022.
Cases that are easy to forget
- Logout and permission changes. When the user logs out in one tab, every tab must drop its cached data, not just refetch it. Broadcast a
logoutmessage and clear the cache. A logout response withClear-Site-Data: "cache", "storage"also asks the browser to clear its HTTP cache and storage for the site, where supported; it is a blunt reset, not a way to invalidate one key. - The back/forward cache. When the user presses Back, the browser may restore the whole previous page from memory, old DOM and all, without running your startup code. Listen for
pageshowand, ifevent.persistedis true, invalidate what could have changed. - Old app code. Until a new service worker activates, open tabs may run old JavaScript against a new API. Prompt a reload when a new worker is waiting.
This lesson covers cross-tab invalidation only as far as caches go. Module 10's lesson on cross-tab sync goes further: leader election so only one tab holds the socket, sharing state rather than just invalidations, and SharedWorker.
Check your understanding · Exercise 8
A user has the cart open in two tabs. They remove an item in tab A, whose code invalidates ['cart'] in its own query cache. What does tab B show? Select all that apply.
Lab: a price changes, layer by layer
Back to the headphones: at 9:00 the price drops from $129 to $99. Choose a strategy for each of four layers and see which of four shoppers see the old price, and for how long.
The setup
- Server cache (Redis): a 5-minute TTL, or delete the key when the price is saved.
- CDN: a 10-minute
s-maxage, a purge of the product page URL only, or a purge by the tagproduct-42, which also drops the category page. - Browser HTTP cache:
max-age=300on the API response, orno-cache, so the browser revalidates with the CDN every time. - Client cache: a
staleTimewith nothing else, refetch on focus, or a WebSocket event that invalidates the query, plus refetch on focus.
The shoppers: Ana opens the product page for the first time at 9:02. Ben has had the product page open and visible since 8:58. Cy opened the product page at 8:50, switched to another tab, and comes back at 9:20. Dee opens the Audio category page at 9:03.
Ana, Ben, and Dee each open their page again at 9:15. Every copy was filled just before 9:00, which is the worst case for TTLs. Ben's and Cy's tabs are already open at 9:00, so only they can receive a WebSocket event.
- Ana (first visit 9:02) sees $129 for 13 min, 9:02 to 9:15.
- Ben (page open since 8:58) sees $129 for 15 min, 9:00 to 9:15.
- Cy (back to his tab at 9:20) sees $129 from 9:20 until a reload.
- Dee (category page 9:03) sees $129 for 12 min, 9:03 to 9:15.
Things to try
- Start with the defaults: TTLs everywhere. Everyone sees $129 for 12 to 15 minutes, and Cy, who never reloads, sees it until he does. Nothing is broken; the TTLs simply add up.
- Switch the CDN to purge by tag, but leave Redis on its TTL. Nothing improves. The first request after the purge refills the CDN from Redis, which still holds $129, and the CDN keeps that for another 10 minutes. This is the order problem from chapter 7.
- Now also delete the Redis key. Ana and Dee see $99 straight away. Ben still sees $129 until he reloads at 9:15: his tab never asks again.
- Add the WebSocket event on the client. Ben's tab refetches at once and still gets $129, because his browser's HTTP cache answers from its 5-minute copy. Switch the browser layer to
no-cacheand Ben updates in about two seconds. - Finally try purging the product URL only. Dee, on the category page, is back to a 12-minute wait: her page was never purged.
The winning combination is where most teams end up: delete in the server cache, purge by tag at the CDN, no-cache in the browser, and an event plus focus refetching in the client. Every layer that can be told is told, in order, and the one that cannot (the browser's HTTP cache) gets no lifetime to hide in.
What real systems do
- Facebook's memcache. The 2013 paper "Scaling Memcache at Facebook" describes delete-on-write, a daemon that reads the MySQL commit log and broadcasts deletes to every cache cluster, and leases against stale sets and thundering herds (stampedes).
- CDN purging. Fastly and Cloudflare offer purge by tag, and both advertise global purges within about a second or less. News sites and shops tag pages with the ids on them and purge on publish, so they can cache HTML at the edge for hours.
- Next.js. Server data fetches can carry tags, and
revalidateTagandrevalidatePathinvalidate the framework's data and page caches after a mutation. Itsrevalidateoption on a page is a TTL; the tag functions are events. - Built assets. Vite, webpack, and Next.js emit content-hashed file names for production builds, so year-long
immutablecaching of JavaScript and CSS needs no invalidation.
Performance and security angles
Every purge turns into misses. Purging a coarse tag at peak can send a wave of requests to the origin. Soft purges (mark stale rather than delete) and request coalescing at the CDN (one origin fetch shared by every request waiting for the same URL) soften it. For security, treat permission changes and logouts as invalidations with zero tolerance: never let a shared cache store per-user responses, and clear client caches in every tab on logout.
Check your understanding · Exercise 9
Redis is deleted on write, the CDN is purged by tag, and the client invalidates on a WebSocket event. The browser layer still uses max-age=300. Who can still see $129 for minutes? Pick one.
Using it in a system design interview
Cache invalidation comes up in almost every frontend design question that has data on screen: a news feed, a shop, a dashboard, a chat app. Interviewers rarely ask "how do you invalidate?" directly. They ask "what happens when the price changes?" or "the user edits their profile; what updates?", and watch whether you think in layers.
When to bring it up
- The moment you add a cache. When you draw a CDN, a service worker, or a client data cache, say in one sentence how a copy in it stops being used.
- When you design a mutation. For every write in your API section, say what it invalidates on the client: which keys, by exact key, prefix, or tag, and whether you write the response into the cache.
- When the interviewer adds realtime. Events are invalidation. A WebSocket message that says
product.updatedshould invalidate a query, not hand-patch the DOM.
What to draw
Draw the chain of copies from the database to the screen, and next to each layer write its strategy and its worst-case staleness. It takes a minute and answers most follow-ups in advance:
| Layer | Strategy | Worst stale window |
|---|---|---|
| Redis | Delete on write, 5 min TTL backstop | About 0, or 5 min if a delete is lost |
| CDN | Purge by tag product-ID, 1 h TTL backstop | Seconds |
| Browser HTTP cache | no-cache for data, hashed names for assets | 0 (one revalidation per request) |
| Client cache | Invalidate on mutation and on event, refetch on focus | Seconds while visible |
| Checkout | Always read the source | 0 |
How deep to go by level
- Junior and mid-level. Know the difference between a TTL and invalidating after a mutation. Use a server-state library and say what you invalidate after each write. Mention hashed file names for assets.
- Senior. Name the layers and how each one is cleared, including the one you cannot clear (the browser's HTTP cache). Add stale windows across layers. Use tags at the CDN. Handle other tabs with focus refetching and a BroadcastChannel. Mention events plus a TTL backstop.
- Staff. Talk about ordering across services, the stale refill race and its fixes (leases, versioned writes), purge storms and soft purges, and how the team keeps tags correct as pages change (tags added where data is loaded, tests that check them).
Check your understanding · Exercise 10
You are designing a dashboard. The interviewer asks: "A teammate edits a chart's title. How fast do other viewers see it?" Which parts belong in a strong answer? Select all that apply.
Summary
- Every cache layer holds its own copy. Changing the database changes only the first one; each other layer needs a TTL, an event, or a new key to drop its copy.
- There are three ways a copy dies: time (TTL), being told (event), or being renamed (versioned key). Tags aim events; hashed file names are versioned keys.
- TTLs in layers that start their own clock add up. Sum them to get the true worst-case stale window.
- Use events for speed and a TTL as the safety net, because events get lost.
- Invalidate from the source outwards: database, server cache, CDN, then clients. Otherwise an outer layer refills from a stale inner one.
- A slow read can refill a cache with old data after your delete. Leases or versioned writes prevent it; a TTL bounds it.
- The server cannot purge a browser's HTTP cache, so keep data that changes on
no-cacheand let the CDN, which you can purge, hold the long lifetime. - After a mutation, write what you know into the client cache and invalidate the rest by prefix or tag; other tabs need focus refetching or a BroadcastChannel.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
A product's price changes in the database. Walk me through every place the old price could still be shown, and how you clear each.
List the chain: Redis (delete the key), the CDN (purge by a product-ID tag so the category and home pages go too), the service worker (drop its copy or use a network-first strategy for data), the browser's HTTP cache (cannot be purged, so serve data with no-cache), the client data cache (invalidate on a WebSocket event and refetch on focus), and the DOM (follows the client cache if views read from it).
Then the order: commit, Redis, CDN, then notify clients, so no layer refills from a stale layer below it. And a TTL in each server-side layer as a backstop for lost events. Checkout reads the price from the source regardless.
Question 2
What is the difference between a TTL, event-driven invalidation, and a versioned key? When would you use each?
A TTL ends a copy after a fixed time; nobody needs to know what changed, but users see old data for up to the TTL and copies refetch even when nothing changed. Use it for data that may be a little old.
An event ends a copy when the data changes; the stale window is the message delay, but it needs plumbing, must reach every affected key, and messages can be lost, so pair it with a TTL. Use it for data that must change fast.
A versioned key gives new content a new name, so old copies are never asked for. Use it for content that is immutable once named, like hashed JS and CSS files; the pointer to the current version (the HTML or a manifest) must itself stay fresh.
Question 3
After a user renames a project, the sidebar still shows the old name. What are the likely causes?
The sidebar uses a different query key that was not invalidated (for example only ['projects', 7] was invalidated, not ['projects', 'list']), or a component copied the name into local state once.
Or the invalidation worked and the refetch was answered by a lower cache: the browser's HTTP cache (the GET had a max-age) or an unpurged CDN. Fix with hierarchical keys and a prefix invalidation, no-cache on editable API data, and purging before the mutation responds.
Question 4
Explain the race where a cache ends up with stale data even though you deleted the key after the write.
A reader misses the cache and reads the old row. Before it stores the value, the writer commits the new row and deletes the key. Then the reader stores the old value, which now lives for the full TTL, and nothing will delete it.
Fixes: leases (a delete cancels the reader's token, so its late write is refused), versioned writes that only store a newer version, a delayed second delete, and always a TTL so the damage is bounded.
Question 5
How do you keep two open tabs of the same app in sync after a change in one of them?
In-memory caches are per tab, so a change in one tab does not reach the other. First, refetch on focus and on reconnect, so a tab is correct by the time the user looks at it.
For changes that must show at once, send the changed key on a BroadcastChannel and let each tab invalidate and refetch it. Send keys, not data. For changes from other users or devices, a server event over each tab's socket. Logout must clear every tab's cache, not just refetch.
Practice it
These design problems lean on cache invalidation.
References
- Two Hard Things (Martin Fowler) The quote attributed to Phil Karlton, and its variants.
- Scaling Memcache at Facebook (NSDI 2013) Delete-on-write, leases, and invalidation from the commit log at scale.
- RFC 9111: HTTP Caching Freshness, the Age header, and how shared and private caches compute it.
- TanStack Query: Query Invalidation Prefix matching, exact, and refetchType.
- Fastly: Working with surrogate keys Tagging responses and purging by key.
- MDN: BroadcastChannel Messaging between same-origin tabs, windows, and workers.
- MDN: Clear-Site-Data Clearing caches and storage for an origin, for example on logout.
Read next
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.
- Server-state librariesTanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.