cf.completefrontend
My progress
FUNDAMENTALS · MODULE 9

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:

  1. The database. The source of truth: when people say "the price changed", they mean this row.
  2. A server cache. Redis or Memcached in front of the database, holding a copy keyed like product:42.
  3. The CDN. A content delivery network: servers near users that keep copies of the product page and the /api/products/42 response until they expire or you purge them (ask the CDN to delete them).
  4. The service worker. A progressive web app may keep its own copy in the Cache API.
  5. The browser HTTP cache. The browser's disk cache, steered by Cache-Control.
  6. The client data cache. TanStack Query, SWR, Apollo, or a Redux store, holding the product in this tab's memory.
  7. The screen. The DOM showing $129 is a copy too; it changes only when a component renders again.
A stack of seven copies of one product price, one second after it changed from $129 to $99. On the server: the database has $99, the source of truth; Redis still has $129 until its TTL ends or the key is deleted. On the network: the CDN edge has $129 until s-maxage ends or it is purged. In the browser: the service worker has $129 until your code drops it, the HTTP cache has $129 until max-age ends and cannot be purged, the client data cache has $129 until it is invalidated, and the screen shows $129 until it re-renders.ServerNetworkBrowserprice, and what ends itDatabase$99source of truthRedis$129TTL or DELCDN edge$129s-maxage or purgeService worker$129your codeHTTP cache$129max-age onlyClient cache$129invalidateScreen (DOM)$129re-render
Only the database changed. Every other layer holds its own copy and has its own way to let go of it, and one of them, the browser's HTTP cache, can only wait.

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.

A state machine for one cached copy. From No copy, a miss fetches the data and the copy becomes Fresh. From Fresh, three things can end it: when the TTL ends it becomes Stale, and a 304 or a refetch makes it Fresh again; an event such as a delete or a purge sends it back to No copy, so the next read misses; and a new key, a versioned name, makes it Unreachable: it is never read again and is evicted later.the life of one cached copyNo copynext read missesFreshserved as isUnreachablenever read againStalecheck before usemisseventnew keyTTL ends304 or refetch
Time moves a copy to Stale, an event removes it, a new name abandons it. Every invalidation tool is one of these three arrows, or a mix, like 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.

QuestionTTLEventVersioned key
Worst stale windowThe TTL (or the sum of TTLs across layers)Time for the message to arrive, if it arrivesTime until the reader learns the new key
Who must know what changedNobodyThe writer, and it must know every affected keyWhoever hands out the key
Extra loadOne refetch per copy per TTL, changed or notRefetches only when data changesNone for old copies; new copies are misses
What breaks itTTL too long for the dataA lost message, a missed key, a raceA cached pointer to the old key
Best forData that may be a little oldData that must change fast, rarelyContent 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:

  1. 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.
  2. Some copies are out of reach. The server can purge its CDN, but not a browser's HTTP cache or a tab's memory.
  3. 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:

DataTolerable stalenessTypical TTL
List of countries, feature copy, help pagesDays1 day, plus a versioned key on deploy
Product description, user avatarMinutes to hours5 to 60 minutes
Price, stock count on a listing pageSeconds to a minute, if checkout rechecks30 to 60 seconds, plus events
Stock and price at checkout, a bank balanceNoneDo not cache; read the source
A user's own data right after they edit itNone for that userInvalidate 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.

A timeline from 0 to 16 minutes after a price change. Redis holds the old price for its 5 minute TTL, from 0 to 5 minutes. At 4 minutes 59 seconds the CDN fetches from the API, which reads the old price from Redis, and the CDN keeps it for its 10 minute TTL, until 15 minutes. At 14 minutes 59 seconds the client fetches from the CDN and keeps the old price for its 1 minute staleTime, until 16 minutes. The screen can show the old price for up to 16 minutes.price changes at 0Redisold, 5 min TTLCDNfilled at 4:59, kept 10 minClientfilled at 14:59, kept 1 min051015 minworst case on screen: about 16 minutes of $129
Each layer starts its own clock when it receives the data, so the worst cases add: 5 + 10 + 1 = 16 minutes, not the 10 of the longest TTL. HTTP caches that pass the Age header share one clock; Redis, the Cache API, and client libraries do not.

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=300 means: 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:

A sequence diagram with six lifelines: the admin service, the database, Redis, the CDN, the realtime service, and an open browser tab. The admin service commits the new price to the database and gets ok. It deletes product:42 in Redis. It purges the tag product-42 at the CDN and gets purged. It publishes a product.updated event for product 42 to the realtime service, which sends a WebSocket event to the tab. The tab invalidates its query and refetches product 42 from the CDN, which misses, refills from the origin, and answers 200 with $99.AdminDBRedisCDNRealtimeTabCOMMITokDEL keypurge tagpurgedpublishWS eventrefetch200, $99
Each step waits for the one before it: the commit before the delete, the delete before the purge, the purge before the clients hear about it. The browser's HTTP cache is not on this diagram, because nothing can tell it.
  1. The admin service commits the new price to the database.
  2. It deletes product:42 in Redis. In larger systems a separate process reads the database's change log and sends the deletes, so no code path can forget to.
  3. 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.
  4. It publishes a product.updated event with the id 42 to a message bus. A realtime service forwards it to connected browsers over a WebSocket or server-sent events.
  5. 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.

Tags, dependency graphs, and versioned keys

Events need a target. Product 42 appears on its own page, on page 3 of the Audio category, in search results, in the home page carousel, and in the /api/products/42 response. Purging by exact URL means knowing all of them, including query strings you have never seen.

Tags: purge by what a response contains

A tag (CDNs also say surrogate key) is a label your server attaches to a response to say which pieces of data it was built from. Later you purge the label, and the cache drops every response that carries it, whatever its URL.

http
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=0, s-maxage=3600
Surrogate-Key: page-category product-42 product-57 category-audio

# later, when product 42 changes (Fastly-style API):
POST /service/SERVICE_ID/purge/product-42
The category page is tagged with every product it shows. One purge of product-42 drops this page, the product page, and every other response tagged with it.

Fastly uses Surrogate-Key, Cloudflare Cache-Tag, Akamai Edge-Cache-Tag. Each CDN reads the header and, by default, removes it before the response reaches the browser. Other layers have the same idea: Next.js tags server fetches and offers revalidateTag, RTK Query has providesTags and invalidatesTags, and in Redis you can keep a set of keys per tag.

Tags describe a dependency graph

Behind tags sits a graph: data on one side, cached outputs on the other, an edge wherever an output used a piece of data. Invalidation walks the edges from the changed data to every output that depends on it.

A dependency graph. On the left, two pieces of data: product 42, which just changed, tagged product-42, and product 57, tagged product-57. On the right, six cached responses. Product 42 is used by /products/42, /api/products/42, /category/audio, the home page carousel, and /search?q=audio. Product 57 is used by /category/audio, /search?q=audio, and /products/57. Purging the tag product-42 drops the five responses that used product 42 and keeps /products/57.datacached responsesp42product-42p57product-57/products/42/api/products/42/category/audio/ (home carousel)/search?q=audio/products/57
A purge of product-42 walks every edge out of the changed data. The two pages that show both products are dropped too, which is correct: they contain the old price. A missing edge is a page that stays stale.

Granularity is the real design work:

  • Too coarse. One tag products on everything means one price change purges the whole catalog, and the origin takes a burst of load right when the sale email lands.
  • Missing. If the home page forgot product-42, it keeps the old price for its full TTL, and nothing errors. Add tags in the same function that loads the data, so a response cannot use an entity without tagging it.
  • Indirect edges. If a brand's name changes, every product page that shows the brand is affected. Either tag product pages with brand-7 too, or accept a TTL for that rarer change.

Versioned keys: change the name, not the copy

The cleanest invalidation is the one you never do. If the key changes whenever the content does, old copies are never asked for again. The best known case is content-hashed file names: the build names a bundle checkout.8c1e2f.js from a hash of its bytes and serves it with Cache-Control: public, max-age=31536000, immutable. A new build makes a new hash, the HTML points to the new name, and no cache anywhere needs to be told.

The same trick works for data:

  • Generation counters. Keep catalog:gen = 17 in Redis and build keys like catalog:17:product:42. To invalidate the whole catalog in one step, increment the counter. New reads use generation 18; old keys age out by TTL or memory eviction.
  • Version in the URL or query key. A small, uncached manifest says { "configVersion": 42 }; the app fetches /api/config?v=42, which the CDN may cache for a year. On the client, a query key ['config', 42] makes the old entry unreachable the moment the version changes.
  • Validators. An ETag is a version too. It does not avoid the request, but it turns a full download into a 304 when nothing changed.

The pointer (the HTML, the manifest) must still stay fresh. And if a deploy deletes old hashed files, a tab still running the old HTML gets a 404 for checkout.8c1e2f.js. Keep old assets for a few days after each deploy.

Check your understanding · Exercise 5

A product's price changes. The category page is cached at the CDN and tagged category-audio only. What happens? 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 }).

Six cached queries after renaming project 7 with Exact key: ['projects', 7] (on screen): refetch now; ['projects', 7, 'members']: untouched; ['projects', 'list', {page:1}] (on screen): still old name; ['projects', 'list', {page:2}]: untouched; ['dashboard']: still old name; ['users', 3] (on screen): untouched.cached query keyafter the rename['projects', 7]refetch now['projects', 7, 'members']untouched['projects', 'list', {page:1}]still old name['projects', 'list', {page:2}]untouched['dashboard']still old name['users', 3]untouched
Green: updated or will refetch. Red: shows project 7 and was missed. Hatched: refetched or marked stale although it does not show project 7. Only queries on screen refetch at once.

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?

Write the response into the cache

The mutation returns the updated project

setQueryData(['projects', 7], project) puts it in place

No refetch for that key

Pros

  • Instant, no extra request
  • Exactly the server's version, not a guess

Cons

  • Only fixes keys you write by hand
  • Lists, counts, and sorted views that include the project are still old

Invalidate exact keys

invalidateQueries({ queryKey: ['projects', 7], exact: true })

That one entry is marked stale and refetches if on screen

Pros

  • Minimal refetching
  • Easy to reason about

Cons

  • You must list every key that shows the project
  • Easy to miss the sidebar or the breadcrumb

Invalidate a prefix

invalidateQueries({ queryKey: ['projects'] })

Matches ['projects', 7], ['projects', 'list', { page: 2 }], and every other key starting with projects

Pros

  • Hard to miss a view, if keys are designed as a hierarchy
  • One line

Cons

  • Refetches more than needed
  • Only works if every view of a project uses a key under projects

Invalidate by tag or type

Queries declare what they show (providesTags: [{ type: 'Project', id: 7 }])

Mutations declare what they change (invalidatesTags: [{ type: 'Project', id: 7 }])

Pros

  • Dependencies live next to each query
  • Works across unrelated key shapes, like a dashboard that shows projects

Cons

  • Needs a library with tags (RTK Query) or your own mapping
  • Same granularity questions as CDN tags

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.

ts
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"],
    });
  },
});
Returning the promise keeps the mutation pending until the lists have refetched, so a spinner does not vanish while the sidebar is still old.

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-cache or private, max-age=0, so the browser always revalidates. A 304 is 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.

Two orders of the same invalidation, side by side. Wrong, outer layer first: 1, purge the CDN; 2, a request misses the CDN; 3, the origin reads Redis, which still has $129; 4, the CDN keeps $129 for 10 minutes; 5, the Redis delete comes too late. Right, from the source outwards: 1, commit $99 to the database; 2, delete product:42 in Redis; 3, purge the CDN; 4, a request misses the CDN; 5, the origin reads $99 from the database.Wrong: outer layer firstRight: source outwards1 purge CDN2 a request misses the CDN3 origin reads Redis: $1294 CDN keeps $129 for 10 min5 DEL Redis (too late)1 commit DB: $992 DEL product:42 in Redis3 purge CDN4 a request misses the CDN5 origin reads DB: $99
Each cache refills from the layer below it. Clear an outer layer while an inner one is still stale and the outer layer copies the stale value for a fresh full TTL.

The safe order is the order the data flows:

  1. 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.
  2. Clear the server cache (DEL product:42).
  3. Purge the CDN by tag, and wait for the purge API to confirm.
  4. Notify clients (WebSocket or server-sent events), which invalidate their in-memory caches and, if needed, ask the service worker to drop its copy.
  5. 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:

The cache-aside race, step 1 of 7. Lifelines: reader A, Redis, the database, and the writer. Reader A asks Redis for product:42 and misses. Redis now holds empty; the database holds $129.Reader ARedisDBWriterGET: missRedis: emptyDB: $129
Arrows appear one step at a time. The race needs only a read that started before the write and finished after the delete.

Step 1: Reader A asks Redis for product:42 and misses.

  1. Reader A misses Redis and reads the database: $129.
  2. Before A writes to the cache, it pauses: a slow network hop, a garbage collection pause, a busy thread.
  3. The writer commits $99 and deletes product:42 from Redis. The cache is empty, which looks correct.
  4. Reader A wakes up and stores $129 in Redis with a 5-minute TTL.
  5. 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 useEffect and useState, 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 a key of 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.

Two browser tabs of the same shop, side by side. In tab A the user removed an item, and the header shows Cart (1); its query cache holds the new cart. Tab B still shows Cart (2) and the removed item, because its query cache is separate memory and still holds the old cart. An arrow from tab A to tab B is labelled BroadcastChannel: invalidate cart. Below both, a shared band lists what the tabs do share: the HTTP cache, the service worker, IndexedDB, and localStorage.Tab A: shop/cartCart (1)Headphones $99item removed herecache A: cart v2Tab B: shop/cartCart (2)Headphones $99Cable $12cache B: cart v1BroadcastChannel: invalidate [cart]shared: HTTP cache, service worker, IndexedDB, localStorage
Shared storage does not make the screens agree. Tab B updates only when something tells it to refetch: focus, a 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, revalidateOnFocus in SWR, both on by default), it is correct by the time anyone looks. This alone fixes most cases.
  • Tell the other tabs. A BroadcastChannel delivers 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.updated event reaches them all, which also covers the same user on another device.
ts
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 });
  }
};
Send keys, not data. Each tab refetches for itself, so no tab can receive a value that is older than one it already has.

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 logout message and clear the cache. A logout response with Clear-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 pageshow and, if event.persisted is 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 tag product-42, which also drops the category page.
  • Browser HTTP cache: max-age=300 on the API response, or no-cache, so the browser revalidates with the CDN every time.
  • Client cache: a staleTime with 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.

Redis:
CDN:
Browser:
Client:
The price drops from $129 to $99 at 9:00. Redis ttl, CDN ttl, browser max-age, client stale-time. 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.Ana: first visit 9:02Ben: page open since 8:58Cy: back to his tab at 9:20Dee: category page 9:039:009:059:109:159:20
Each red bar is the time a shopper is looking at the old price, from 9:00 to 9:22. A hatched bar runs on until they reload.
  • 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

  1. 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.
  2. 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.
  3. 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.
  4. 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-cache and Ben updates in about two seconds.
  5. 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 revalidateTag and revalidatePath invalidate the framework's data and page caches after a mutation. Its revalidate option 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 immutable caching 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.updated should 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:

LayerStrategyWorst stale window
RedisDelete on write, 5 min TTL backstopAbout 0, or 5 min if a delete is lost
CDNPurge by tag product-ID, 1 h TTL backstopSeconds
Browser HTTP cacheno-cache for data, hashed names for assets0 (one revalidation per request)
Client cacheInvalidate on mutation and on event, refetch on focusSeconds while visible
CheckoutAlways read the source0

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-cache and 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.

Question 2

What is the difference between a TTL, event-driven invalidation, and a versioned key? When would you use each?

Question 3

After a user renames a project, the sidebar still shows the old name. What are the likely causes?

Question 4

Explain the race where a cache ends up with stale data even though you deleted the key after the write.

Question 5

How do you keep two open tabs of the same app in sync after a change in one of them?

Practice it

These design problems lean on cache invalidation.

References

Read next