Command Palette

Search for a command to run...

Hectal
PHASE 24Advanced ~30 min· topic 9 of 10

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.

Pick the right layerdiagram
Rendering diagram…

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.

Optimistic, then confirm or roll backdiagram
Rendering diagram…

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. 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. 2

    Fail the first request

    In the like button, make calls === 1 fail instead. Predict all four lines before running.

  3. 3

    Remove the outbox

    In the notes example, send notes even when offline (delete the else setOutbox branch and always call sendNote). 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

Stale-while-revalidate with memory and localStorage caches New tab

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

Press Run to see the page.

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)
Optimistic like with rollback New tab

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

Press Run to see the page.

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 unavailable
Offline detection, a saved draft and an outbox New tab

The 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

Press Run to see the page.

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: 0
Optimistic updates with TanStack Query and IndexedDB with idb-keyval (not live)jsx
import { 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")) ?? [];
A minimal service worker: cache the shell, network-first for API calls (not live)javascript
// 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.

terminal
$ Preview (click twice fast, second fails)
── what you'll see ──
The count ends up wrong: it rolls back to a value from before the first click, undoing a like the server accepted.

Break #2

Unguarded localStorage

Remove the try/catch around localStorage.getItem and store a broken value: localStorage.setItem("price:BTC", "{oops") before rendering.

terminal
$ Console
── what you'll see ──
Uncaught SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
(the whole component fails to render)

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 (persistQueryClient with 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.js installs in the background but waits until all tabs using the old one are closed. Show an "Update available" prompt and call skipWaiting() 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, and navigator.storage.estimate() reports usage and quota. Safari deletes script-writable storage for sites the user hasn't interacted with for 7 days.

  • ▸

    The storage event fires in other tabs when localStorage changes, which lets you sync a draft or a logout across tabs; read it through useSyncExternalStore so every tab renders the same value (Topic 10.3).

Remember this

  1. 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, so JSON.stringify; sessionStorage lasts 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 as idb-keyval or idb. (4) The HTTP cache: the browser itself keeps responses according to Cache-Control headers 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, intercepts fetch requests and can answer them from the Cache Storage API, which is what makes a site load with no network at all.

  2. 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 (staleTime says how long data counts as fresh), of SWR (the library is named after it) and of the stale-while-revalidate HTTP directive. Show a small "updating" hint, never a full-page spinner, while revalidating.

  3. 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's onMutate/onError pattern does the same with its cache. Don't use optimism where failure is common or costly (payments, bookings).

  4. 4

    Online and offline. navigator.onLine and the window events online and offline say 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 with useSyncExternalStore (Topic 10.3). TanStack Query already pauses requests while offline and resumes them when back online.

  5. 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. 6

    Persistence gotchas. Storage can throw (private browsing modes, full quota, blocked by settings), so wrap reads and writes in try/catch and 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 (prefer HttpOnly cookies), 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 with useSyncExternalStore's getServerSnapshot.

Explain it without notes

01

Compare memory, localStorage, IndexedDB, the HTTP cache and service workers as caching layers.

02

What is stale-while-revalidate and why does it improve UX?

03

How do you implement an optimistic update safely?

04

How would you make a form work offline without losing data?

Practice

01

Write a usePersistentState(key, initial) hook that works like useState but saves to localStorage, safely.

02

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 staleTime appropriate 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.