Stale-While-Revalidate
Render what you have, fetch what is current, write it in — and take responsibility for the fact that the page changed under the person reading it.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Should I show the user data I already have while I check whether it is still true?
Someone comes back to a screen they saw a minute ago. They want it now, and they want it right. When those two conflict they want to know which one they are being given.
If the cache has it, render it. Fetch in the background and swap the result in when it lands. The user gets an instant screen and a correct screen, and nobody has to choose.
The swap happens while they are reading. A row is inserted at the top, everything below shifts, and the item they were reaching for is now somewhere else (Visual Stability).
- The swap happens while they are reading. A row is inserted at the top, everything below shifts, and the item they were reaching for is now somewhere else (Visual Stability).
- Content that changes with no explanation reads as a bug. Users who cannot tell whether a screen is live start reloading it manually — the exact behaviour the pattern was built to remove.
- When revalidation fails, the naive version renders old data and calls it success. There is no error state because there is data, so an app that has been offline for ten minutes looks perfectly healthy (Network Failures Only the Client Can See).
- For a balance, a remaining-seats count or a permission, "instant but possibly wrong" is worse than a spinner. The user acts on the number, and the action fails (Authorization-Aware UI).
- Every mounted query revalidating on window focus means a burst of requests each time the user alt-tabs back — and users alt-tab constantly.
What is actually happening
In the browser, not in the framework.
- Four steps, in order: read the entry, render it, fetch, write the response into the entry and notify subscribers — which produces a second render. The user sees two frames of the same screen with possibly different content.
- The pattern is older than the libraries that popularised it. HTTP has a
stale-while-revalidateresponse directive that lets a cache serve a stored response past its freshness lifetime while refreshing it in the background. Same idea, different layer, different controls: HTTP's version is decided by the server in a header and applies to the browser and CDN caches; the client cache's version is decided by your code (Browser HTTP Caching, CDN Delivery). - Revalidation only happens when something triggers it. The triggers — mount, window focus, network reconnect, an interval, an explicit invalidation — are the policy, and each is a separate decision with a separate cost.
- Structural sharing decides whether the second render is free. If the response is deep-equal to what is already stored, a cache that preserves the previous object references produces a write that changes nothing, so subscribers do not re-render and the DOM is untouched.
- Stale is not an error state and not a loading state. An entry can be simultaneously stale, rendering, and fetching, and a UI that models status as a single enum cannot express that (Loading, Error, Empty — The States You Did Not Render).
What this makes the browser do
And which of it is avoidable.
- Two renders per revalidation instead of one: the cached render, then the write. When the data is unchanged and references are preserved, the second costs nothing; when it changed, it costs reconciliation, DOM mutation, style, layout and paint for the region (The Cost of a Change).
- The request itself is off the main thread, but the response is not: parsing the JSON and diffing it against the stored value are both synchronous main-thread work proportional to payload size.
- Refetch-on-focus multiplies all of it by the number of mounted queries, and it lands in the same moment the user is trying to interact after switching back to the tab (Interaction Responsiveness).
- If the fresh data has a different height, the write costs layout for everything below it and shifts content the user was aiming at (Layout Thrashing).
Four steps, and the one that is a design decision
Three of the four steps are mechanical. Reading the entry, issuing the fetch and writing the response are things the cache does; there is no judgement in them and little to get wrong. The fourth — rendering the stale value in the interval, and then replacing it — is the one that reaches the user, and it is not a technical step at all.
The useful way to read the pipeline below is to ask, at each step, what the user is doing at that moment. At step two they are looking at content. At step four they are still looking at it, and it changes. Everything in the design list follows from taking that seriously.
- 11. Read the entry
Look up the key. A hit returns data plus its age; freshness is computed from the age, not stored.
fails by The key omits a parameter, so the hit is for a different question and the whole sequence is wrong from here on (Query Keys and Invalidation).
- 22. Render what is there
The user gets content in the first frame, with no loading state and nothing to wait for.
fails by Rendering a skeleton anyway, because the code models "fetching" as "no data", which throws away the entire benefit.
- 33. Decide whether to check
A trigger fires — mount, focus, reconnect, interval, invalidation — and the entry is stale, so a fetch starts.
fails by Every trigger enabled on every query, so the app revalidates constantly and the request volume is set by user alt-tabbing.
- 44. Fetch, deduplicated
One request per key regardless of how many views asked. Concurrent callers join the in-flight promise (Five Components, One Request).
fails by No in-flight slot, so a mount and a focus event in the same tick produce two requests whose responses race (Out-of-Order Responses).
- 55. Write the response
Replace the entry's data, reset its age, and preserve object references for the parts that did not change.
fails by No structural sharing, so an unchanged response still re-renders every subscriber and rebuilds the list.
- 66. Render again — under the reader
Subscribers re-render. If anything changed, the DOM changes while the user is looking at it.
fails by Applying the write during an open menu, a focused row or a dirty form, so the interaction the user was in the middle of breaks.
- 77. Or: the fetch failed
Keep the data, mark the entry errored, and tell the user what they are looking at is not current.
fails by Swallowing the error because data exists, so an offline app looks perfectly healthy (Offline UX).
Steps 1 to 5 are the cache's job and are largely solved by any competent library. Steps 6 and 7 are yours, and no library can make them for you, because both are questions about what the interface owes the person in front of it.
What the user actually experiences
The reason this pattern feels free in development is that the second render usually arrives before you have finished looking at the first. On a real connection the gap is long enough for a person to start reading, decide what to press, and begin moving toward it — and the write lands in the middle of that.
The timeline below is the failure case rather than the happy one, because the happy one needs no explanation. It shows the shape of the problem: the user's attention and the network are two independent processes, and the pattern couples them at exactly the wrong moment.
- Cache read: hit, entry is stale — No request yet. The data is already in the heap.
- Render from cache — content on screen — This is the whole benefit, and it is real.
- Revalidation request in flight — Invisible to the user unless you indicate it.
- Response parsed, entry written — A new row sorted to the top. One row longer than before.
- Pointer lands — on the row that moved into place — The user opens something they did not choose.
Nothing failed. The cache worked, the request succeeded, the data is correct. The bug is entirely in step six: the interface changed under a person who was already committed to an action. Reserving space, holding the write while a pointer is over the list, or applying additions behind a "3 new items" control all remove it — and all of them are choices somebody has to make.
Content that changes under the reader
Visually, a background update is a small change. To a screen-reader user it can be a much larger one: the content they were navigating has been replaced, their reading position may be gone, and if the region was rebuilt rather than updated, so is their focus.
The specification below is the pattern in full — what the region is, what stays put, what gets announced and what does not. The single most important line is the last one: the mistake this pattern invites is replacing the region with a skeleton on every revalidation, which destroys the focused node for a benefit that only exists when there is no data at all.
semantics A section with an accessible name from its heading, containing a ul/li list with stable ids per row. A separate, always-present role="status" element for announcements. aria-busy="true" on the section only while a revalidation is in flight.
| Tab / Shift+Tab | Moves through the same controls in the same order before and after the update — tab order must not be reordered by a background write. |
| Arrow keys (in a composite widget) | Continue from the currently focused row; the roving tabindex must survive the write (Accessible Component Patterns). |
| Screen-reader browse keys | Reach the status region as ordinary content, so the last announcement can be re-read on demand rather than only heard once. |
| Escape (if an update is deferred behind a control) | Dismisses the "new items available" affordance without applying it. |
- — Never call
focus()as part of a background update. The user did not act, so nothing should move. - — Keep the focused element the same DOM node across the write. Preserving keys is what makes this true; re-keying on every response is what makes it false (Node Identity Across Updates).
- — If the focused row is removed by fresh data, move focus deliberately to the nearest surviving sibling and announce it — do not let it fall to
body. - — Hold the write while a menu, popover or inline editor inside the region is open. Applying it closes the thing the user opened.
- — Politely, once, on a change that matters: "Updated. 3 new results." Silence when the response was identical.
- — On failure: "Showing results from earlier — could not refresh." The data stays; the announcement says what it is.
- —
aria-busyon the region while fetching, so a screen reader can suppress partial reads of content that is about to change. - — Never announce from inside the region being replaced — the announcement is destroyed along with it. The status element lives outside and persists (Live Regions and Announcement).
usually broken by The pattern invites swapping the region for a skeleton on every revalidation. It is one line of code, it looks responsive, and it destroys the focused element, resets the user's reading position, produces a layout shift in both directions, and replaces content that was perfectly usable with content that is not. A skeleton is for an empty cache; a busy attribute is for a full one.
How to build it
Most important first.
- Choose the staleness window per query from two facts: how often the data actually changes, and what showing an old value costs the user. Those are the inputs; a global default is a placeholder for not having asked.
- Indicate revalidation without moving anything. A small busy affordance in a reserved space plus
aria-busyon the region — never a skeleton laid over content the user is already reading (Visual Stability). - Do not write into a region the user is actively using. If a menu is open, a row is focused, or a form is dirty, hold the response and apply it when the interaction ends (Form State Is a Draft).
- Separate "stale and revalidating" from "revalidation failed". Keep rendering the data in both cases, and surface the failure in the second so the user knows what they are looking at (Network Failures Only the Client Can See).
- Opt out where stale is dangerous. Some queries should render a loading state rather than an old value, and that is a per-query decision the same as the staleness window.
- Bound the triggers. Focus, interval and reconnect revalidation all at once, across dozens of mounted queries, is a load pattern rather than a freshness strategy (Thundering Herd).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A background update is a content change the user did not initiate. It must be announced politely through a live region that was in the DOM before the change, and it must not move focus (Live Regions and Announcement, Focus Management).
- Announce the outcome, not the event. "Updated — 3 new results" is useful; announcing every revalidation that changed nothing is noise that trains the user to tune out the region.
- Preserve node identity across the write so the focused element is the same DOM node afterwards. A re-keyed list drops focus to
body, which for a keyboard user means starting the page again (Node Identity Across Updates, Reconciliation and Keys). - Never replace content the user is reading with a skeleton. A skeleton is correct when there is nothing to show and wrong when there is (Loading, Error, Empty — The States You Did Not Render).
- Reduced-motion users should not get an animated transition on the swap; the change of content is the information, and the animation is not (Contrast, Colour and Motion).
What can go wrong
- The focus storm: a user alt-tabs back and every mounted query revalidates in the same task, producing a burst of requests and a burst of parses at the exact moment they want to click something.
- Perpetual revalidation, where interval plus focus plus reconnect triggers overlap so the screen is never not fetching, and the busy indicator is permanently on.
- Layout shift when the fresh data is longer or shorter than the stale data, so the update moves the thing the user was about to press (Visual Stability).
- A silent failure path: revalidation errors are swallowed because there is still data to render, and the app happily shows hour-old content as though it were live.
- The mitigation failing: you add an "updating" indicator, it appears on every revalidation of every region, and within a week users have learned to ignore it — so it no longer communicates anything when it matters.
- A revalidation resolves while the user is mid-interaction — a menu open, a row focused, a drag in progress — and the write moves the target out from under them.
- Two triggers fire in the same tick (a mount and a focus event), producing two requests for one key unless the cache deduplicates in-flight requests (Five Components, One Request).
- A revalidation started before a mutation resolves after the optimistic write, so the older server value overwrites the newer local one. Cancelling in-flight queries for the key before applying an optimistic update is the standard defence (Optimistic UI).
- Two revalidations for the same key overlap and the first-sent resolves last, so the entry ends up holding the older answer (Out-of-Order Responses).
- A response arrives after the component unmounted and the entry was garbage-collected, resurrecting an entry nobody is rendering.
- A background revalidation carries the user's credentials. If the session expired while the tab was idle, the refetch is the moment that becomes visible, and the UI must handle the rejection rather than continuing to render the authorized-looking cached content (Session Expiry and the Refresh Race).
- Stale data must never decide what the user is allowed to do. A permission revoked server-side is still in the cache, and rendering an action as available is a UI decision; whether it succeeds is a server decision (What the Frontend Is Responsible For in Auth, Authorization-Aware UI).
- Refetch-on-focus from every open tab of every user is a real load and rate-limit surface. The client's revalidation policy is part of the API's capacity plan, not a purely local choice (The Rate-Limit Contract).
- Revalidating a query whose response is scoped to an identity that has since changed will write another identity's data into an entry the previous identity is rendering — the key must carry the identity (Query Keys and Invalidation).
- "Stale-while-revalidate gives you speed and correctness with no downside." It gives you speed immediately and correctness shortly afterwards, and it spends the interval in between showing something that may be wrong. That interval is the design problem.
- "It is the same as the HTTP directive." Same idea, different layer. The HTTP directive is the server telling the browser and CDN how to behave; the client cache version is your code deciding, with completely different triggers and no header involved (Browser HTTP Caching).
- "If the data is unchanged, the revalidation was wasted." It was the check that lets you keep rendering instantly next time. What you should avoid is the wasted *render*, and structural sharing is how.
- "Showing a spinner would be worse." For a seat count, a balance or a permission, a spinner is a promise that the number is current. Rendering a stale number with no indication is a promise you cannot keep.
Measuring it, and what changes in the field
- The Network panel, filtered to fetch requests, with the tab blurred and refocused: the burst you see is your focus-revalidation policy made visible (Debugging the Network).
- Library devtools distinguish
isStalefromisFetching, which is the difference between "this needs checking" and "this is being checked" — a distinction the UI usually needs too (Debugging State). - Visual stability measurement catches the updates that move content. A background write that shifts layout registers as a shift the user did not cause, which is the worst kind (Visual Stability).
- In the field, count background requests per session and per user-visible interaction; a ratio that climbs as the app grows is a revalidation policy nobody has revisited (Real User Monitoring).
- On a slow network the stale render is most of the value — the gap between the first frame and the second is where the user does their reading, and it can be seconds long.
- On a slow device the second render is the expensive part, especially for a long list; structural sharing is what keeps an unchanged revalidation from costing a full re-render (List Virtualization).
- On a metered or battery-constrained device, interval revalidation of a screen nobody is looking at is a cost with no benefit. Pause it when the document is hidden (The Half of the Budget You Cannot See From the Server).
- In a long-lived tab, the accumulated effect of every trigger firing on every mounted query is far larger than it appears in a five-minute development session (Long-Lived Clients and Version Skew).
- Instant content costs you a screen that can change under the reader. That is a real cost paid by real users, and the mitigations — indication, deferral, reserved space — are work, not defaults.
- A short staleness window buys freshness and costs requests, server load, battery and the visual churn of frequent updates.
- Deferring writes while the user is interacting buys stability and costs you a queue of pending updates, plus a decision about what to do when the deferred data is itself stale by the time it applies.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe sequence — render cached, fetch, write, render again — and the UX consequence that content changes under the reader are the same in every implementation, including a hand-written one. Only the trigger set and the indication vocabulary differ.
- FRAMEWORK-SPECIFICSWR revalidates on mount, focus and reconnect by default and exposes
isValidating; TanStack Query gates the same triggers behindstaleTimeand separatesisPendingfromisFetching; RTK Query revalidates on explicit tag invalidation, polling intervals or focus when enabled; Apollo'scache-and-networkfetch policy is the closest equivalent and is set per query rather than globally. Copying trigger advice between them produces either constant refetching or none. - SPEC-EVOLVINGThe HTTP
stale-while-revalidatecache directive is supported unevenly across browsers and CDNs, and CDN vendors implement their own variants with different names and semantics. Treat it as a server-side optimisation to verify per platform, not as a portable guarantee.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — showing a stale replica while asking the authority is read-your-writes weakened on purpose, and the interval between the two frames is the window in which the client and the server disagree.