Topic 24.9
Offline Support, Caching and Persistence
In one line
A resilient app keeps copies of data in layers (memory, localStorage, IndexedDB, the HTTP cache and a service worker), shows cached data at once and refreshes it in the background (stale-while-revalidate), updates the screen optimistically and rolls back on failure, notices when the network goes away, and saves drafts and queued actions so nothing the user typed is lost.
Think of it like this
A notebook you carry on a train. When the train goes into a tunnel you can still read what you copied down earlier (a cache), and you can keep writing new notes. When you're out of the tunnel, you post them to your friends (sync). If a friend replies "I didn't get that", you cross it out and write it again (rollback and retry). You never throw away a half-written note just because the signal dropped (drafts).
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Cache
- A saved copy of data kept close by so it can be shown quickly without asking the server.
- Stale data
- A cached copy that may be out of date but is still useful to show.
- Stale-while-revalidate
- Showing cached data at once while fetching a fresh copy in the background.
- IndexedDB
- An asynchronous database built into the browser for large or structured data.
- Service worker
- A background script that can intercept the page's network requests and answer them from a cache, even offline.
- Optimistic update
- Changing the screen before the server confirms, assuming the request will succeed.
- Rollback
- Putting the previous state back when an optimistic update fails.
- Outbox
- A queue of actions made while offline, sent in order when the connection returns.
- Idempotent
- Safe to repeat: doing it twice has the same effect as doing it once.
Step by step
01Pick the right layer
Small settings and drafts: localStorage. Large lists, files, offline data: IndexedDB. Server responses: TanStack Query in memory plus HTTP caching headers. Whole-app offline: a service worker.
02Stale-while-revalidate in a hook
Initialise state from the cache (lazy useState initialiser), fetch fresh data in an effect, write it back to the cache and swap it in. Ignore the response if the component has unmounted or the key changed.
03Optimistic, then confirm or roll back
Save the confirmed state, update the screen, await the server. On failure restore the saved state and show an error.
04Track connectivity
Subscribe to online/offline with useSyncExternalStore. Show a banner, queue writes while offline, and flush the queue when back online.
05Cache the app shell with a service worker
A service worker caches the HTML, JavaScript and CSS on install and answers from the cache when the network fails, so the app opens offline. Most teams generate it with Workbox or a framework plugin rather than writing it by hand.
Try it yourself
- 1
Make the server slow
In the SWR example, change the fake API delay to 1000 ms and the hidden click to 1200 ms. Predict which lines appear and in what order. Visit 2 still shows the cached price instantly, which is the whole point.
- 2
Fail the first request
In the like button, make
calls === 1fail instead. Predict all four lines before running. - 3
Remove the outbox
In the notes example, send notes even when offline (delete the
else setOutboxbranch and always callsendNote). In a real app those requests would fail and the notes would be lost. What would you need to add to retry them?
Code & diagrams
The first visit has no cache, so it shows a skeleton. On the second visit (the `key` change remounts `Price`) the cached price appears instantly while a fresh one loads, then it's swapped in. In the preview `localStorage` is in memory and resets on each Run.
Preview
Console
Nothing logged yet.Expected console output
Visit 1: shows a skeleton (checking for updates)
Visit 1: shows 100 (fresh)
Visit 2: shows 100 (checking for updates)
Visit 2: shows 105 (fresh)Both clicks update the button at once. The first request succeeds; the second fails, so the button goes back to the last confirmed state and an alert explains why.
Preview
Console
Nothing logged yet.Expected console output
Right after click 1: ♥ 42
Server said ok: ♥ 42
Right after click 2: ♡ 41
Server failed: ♥ 42 | Couldn't save: Server unavailableThe script fires the browser's `offline` event, adds two notes (they wait in the outbox), then fires `online`: the outbox is sent in order. The draft in the input is saved on every change. Real code would persist the outbox too (IndexedDB) so it survives a reload.
Preview
Console
Nothing logged yet.Expected console output
In the tunnel: Offline: notes will be sent later | Sent: none · Waiting: 2
Back online: Online | Sent: Buy milk, Call Asha · Waiting: 0import { useMutation, useQueryClient } from "@tanstack/react-query";
import { get, set } from "idb-keyval"; // IndexedDB as a simple async key-value store
export function useToggleLike(postId) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (liked) => fetch("/api/posts/" + postId + "/like", { method: liked ? "PUT" : "DELETE" }),
onMutate: async (liked) => {
await queryClient.cancelQueries({ queryKey: ["post", postId] }); // don't let a refetch overwrite us
const before = queryClient.getQueryData(["post", postId]);
queryClient.setQueryData(["post", postId], (p) => ({ ...p, liked, likes: p.likes + (liked ? 1 : -1) }));
return { before }; // becomes "context" below
},
onError: (_err, _liked, context) => queryClient.setQueryData(["post", postId], context.before), // roll back
onSettled: () => queryClient.invalidateQueries({ queryKey: ["post", postId] }), // re-sync
});
}
// Offline-first storage of a big list:
export const saveOutbox = (items) => set("outbox:v1", items);
export const loadOutbox = async () => (await get("outbox:v1")) ?? [];// sw.js, registered with: navigator.serviceWorker.register("/sw.js")
const SHELL = "shell-v3";
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(SHELL).then((c) => c.addAll(["/", "/assets/app.js", "/assets/app.css"])));
});
self.addEventListener("activate", (event) => { // delete old versions
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k)))));
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith("/api/")) {
// API: try the network, fall back to the last cached answer when offline.
event.respondWith(
fetch(event.request)
.then((res) => { const copy = res.clone(); caches.open("api").then((c) => c.put(event.request, copy)); return res; })
.catch(() => caches.match(event.request)),
);
} else {
// App shell: cache first, so the app opens instantly and offline.
event.respondWith(caches.match(event.request).then((hit) => hit ?? fetch(event.request)));
}
});Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Rolling back to a stale value
In the like button, roll back with setCount(count) and setLiked(liked) read from the closure after two quick clicks.
Break #2
Unguarded localStorage
Remove the try/catch around localStorage.getItem and store a broken value: localStorage.setItem("price:BTC", "{oops") before rendering.
Myth vs fact
Myth
navigator.onLine === true means the server is reachable.
Fact
It only means the device has some network connection. Captive portals, DNS failures and server outages all look "online". Use request failures as the real signal.
Myth
localStorage is a good place for large data.
Fact
It's small, synchronous (it blocks the main thread) and stores only strings. Use IndexedDB for anything big or structured.
Myth
A service worker makes a site offline automatically.
Fact
It only does what its code says. You choose what to cache, which strategy each request uses, and how to update old caches; a bad worker can serve stale code for a long time.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
TanStack Query can persist its whole cache to localStorage or IndexedDB (
persistQueryClientwith a persister) so a reload starts with yesterday's data and revalidates, and paused mutations made offline can be resumed after reload. - ▸
Service worker updates: a new
sw.jsinstalls in the background but waits until all tabs using the old one are closed. Show an "Update available" prompt and callskipWaiting()when the user agrees, or users may run old code for days. - ▸
Browsers can evict storage under pressure.
navigator.storage.persist()asks for persistent storage, andnavigator.storage.estimate()reports usage and quota. Safari deletes script-writable storage for sites the user hasn't interacted with for 7 days. - ▸
The
storageevent fires in other tabs when localStorage changes, which lets you sync a draft or a logout across tabs; read it throughuseSyncExternalStoreso every tab renders the same value (Topic 10.3).
Remember this
- 1
Caching layers, fastest to most durable. (1) Memory (a
Map, React state, the TanStack Query cache): instant, gone on reload. (2) localStorage / sessionStorage: small (around 5 MB per origin), synchronous (blocks the main thread on large reads), strings only, soJSON.stringify;sessionStoragelasts for one tab. (3) IndexedDB: an asynchronous database in the browser for large and structured data (thousands of records, files and blobs), usually used through a small wrapper such asidb-keyvaloridb. (4) The HTTP cache: the browser itself keeps responses according toCache-Controlheaders set by the server (max-age,stale-while-revalidate,no-store). (5) A service worker: a script that sits between your page and the network, interceptsfetchrequests and can answer them from the Cache Storage API, which is what makes a site load with no network at all. - 2
Stale-while-revalidate (SWR). Show the cached copy immediately (it may be stale, a bit old), fetch a fresh copy in the background (revalidate), then swap it in. Users see content instantly and it corrects itself a moment later. It's the default behaviour of TanStack Query (
staleTimesays how long data counts as fresh), of SWR (the library is named after it) and of thestale-while-revalidateHTTP directive. Show a small "updating" hint, never a full-page spinner, while revalidating. - 3
Optimistic updates with rollback. For actions that almost always succeed (likes, toggles, renames, reordering), change the screen before the server answers, so the app feels instant. Remember the previous state, send the request, and if it fails roll back to the saved state and tell the user. React 19's
useOptimistic(Topic 19.6) shows an optimistic value only while an Action is running and drops it automatically when the Action ends; TanStack Query'sonMutate/onErrorpattern does the same with its cache. Don't use optimism where failure is common or costly (payments, bookings). - 4
Online and offline.
navigator.onLineand the window eventsonlineandofflinesay whether the device has a network connection. They can't tell whether the server is reachable (a café's Wi-Fi login page counts as "online"), so treat failed requests as the real signal and use these events for hints: a banner, pausing polling, retrying when connectivity returns. In React read it as an external store withuseSyncExternalStore(Topic 10.3). TanStack Query already pauses requests while offline and resumes them when back online. - 5
Syncing drafts and queued actions. Save what the user is typing as they type (to localStorage or IndexedDB, debounced for big text) and restore it on reload, then clear it after a successful submit. Actions done while offline go into an outbox queue, persisted the same way, and are sent in order when the connection returns. Give each queued action a client-generated id so the server can ignore a duplicate if a retry succeeds twice (idempotency). For conflicts (the same record changed elsewhere), decide a rule up front: last write wins, merge fields, or ask the user.
- 6
Persistence gotchas. Storage can throw (private browsing modes, full quota, blocked by settings), so wrap reads and writes in
try/catchand fall back to memory. Stored data outlives your code, so add a version to what you save and migrate or discard old shapes. Never store secrets or tokens where any script on the page can read them if you can avoid it (preferHttpOnlycookies), and remember that cached personal data stays on shared computers until cleared (clear it on logout). Reading storage during render on the server is impossible, so in SSR apps read it in an effect or withuseSyncExternalStore'sgetServerSnapshot.
Explain it without notes
Compare memory, localStorage, IndexedDB, the HTTP cache and service workers as caching layers.
What is stale-while-revalidate and why does it improve UX?
How do you implement an optimistic update safely?
How would you make a form work offline without losing data?
Practice
Write a usePersistentState(key, initial) hook that works like useState but saves to localStorage, safely.
Add a "You're offline" banner to any app using useSyncExternalStore.
Trade-offs
- ↔
Showing cached data makes apps fast and offline-friendly, but users may briefly see old data; mark it as updating and keep
staleTimeappropriate to how fast the data changes. - ↔
Optimistic UI feels instant but needs rollback code and careful handling of overlapping requests; pessimistic UI (wait for the server) is simpler and safer for important actions.
Done when you can
I can choose between memory, localStorage, IndexedDB, HTTP caching and a service worker.
I can implement stale-while-revalidate in a hook.
I can write an optimistic update with a correct rollback.
I can detect online/offline with useSyncExternalStore and queue actions in an outbox.
I can save and restore drafts safely, with try/catch and versioned keys.