01 / The prompt
“Build me a mail client with unread counts.”
A sidebar with Inbox and Updates and how many unread messages each has, a message list, and the Inbox count in the tab title so you notice new mail from another tab. Click a message and it is read. What comes back works: counts appear, the list loads, the message you click stops being bold.
Then look at the sidebar. The list changed because the list changed its own data. The sidebar fetched its own copy of the counts when it mounted, and nothing told it that a message was read. It says 2 while the server says 1, and it will keep saying 2 until something makes it fetch again.
The question the prompt never answered is that the server owns these numbers and the page holds copies. Copies need a name, an age, and a rule for when to throw them away. TanStack Query’s defaults put it bluntly: queries “by default consider cached data as stale” (Important Defaults, fetched 23 September 2026).
02 / Name the shape
Fetched data is a replica.
Server state is data the server owns and the client only borrows: counts, lists, a user’s profile. The client’s copy is a replica, and a replica needs four things the server’s original never did: a key that says what was asked, an age after which it is stale, one read at a time per key, and a way to be told when a write made it wrong.
Keep every copy of server data in one cache, under the key you asked for. When you change the data, invalidate the keys that change touched.
Here is who owns what in the mail client.
| State | Owner | Why |
|---|---|---|
| Which messages exist, and which are read | The server | Other devices read and change them too. |
| Unread counts, the folder lists | The server; the client holds cached copies under keys | Several components show them. One copy per key keeps them the same. |
| How old a copy may get | The cache’s stale time | A product decision, written once, not left to each component. |
| Which copies a write made wrong | The code that makes the write | Only it knows the write touched the counts and one folder. |
| Which folder is selected | The URL | Client state. It chooses a key; it is not server data. |
Words to put in a prompt or a review
- Query key
- The name of a copy: what was asked for, including every parameter.
- Stale time
- How long a copy counts as fresh. A stale copy is shown and refetched.
- Invalidation
- Telling the cache a write made some copies wrong, so readers fetch again.
- Deduplication
- Two readers of one key share one request.
- Stale-while-revalidate
- Show the old copy while the new one loads, and say so.
- Optimistic update
- Change the copy before the server answers, and let the refetch confirm it.
Isn’t the server the one that should push changes?Polling, SSE, WebSockets
It can, and for another device’s changes it has to: a cache can only refetch when something tells it to. Invalidation covers your own writes. Stale times and refetch-on-focus cover other people’s, roughly. When roughly is not enough, the server has to announce changes, and Polling, server-sent events, or WebSockets is that decision. Whatever arrives still lands in the cache under its key.
03 / Follow one unread count
Watch the same click on two mail clients.
First the build where each component fetches its own copy: mark a message read, then switch folders before a reply arrives. Then the same on one cache, and what the cache does when a copy gets old and the refresh fails. Step through, or open Try it and run the network yourself.
Whose copy of the unread count is on screen?
Each component keeps a copy
The page · tab title Mail
- Inbox …
- Updates …
Inbox · loading
- Loading…
Mail server
Unread: Inbox 2 · Updates 2
Reads sent: 2
Replies in flight: 2
The page opens; the tab badge mounts too.
Three components, three reads in flight: 3.
Reduced motion: choose a scene to see its completed state.
Read this scene
Three components, three reads in flight: 3.
Each component keeps a copy. The page opens; the tab badge mounts too. Folder inbox, list loading (loading). Sidebar Inbox loading; the server has 2.
Watch restarts the story when you come back. Step through keeps your step. Try it starts both builds fresh each time you open it.
04 / Read the shape
The key names the copy. The write names what it broke.
Basic form is the cache itself, about eighty lines: entries by key, a stale check, one read per key, and a generation number on every read. In the wild is the mail client on top: which components read which keys, and which keys “mark as read” invalidates. At the call site the page reads everything by key, with a status it can show.
TanStack Query, SWR, and Apollo are production versions of the basic form, with retries, garbage collection, and devtools. The lesson’s version is small so you can see the rules.
The cache: one entry per key, a stale time, at most one read in flight per key, and a generation number so a reply for a read that was replaced is ignored. A failed refresh keeps the last good copy.
export type Status = 'loading' | 'fresh' | 'stale' | 'refreshing' | 'error';
/** A read the cache asks for. `reply` is called once, when the answer arrives. */
export type Fetcher<T> = (key: string, reply: (ok: boolean, data?: T) => void) => void;
interface Entry<T> {
data: T | null;
updatedAt: number;
/** Which read the entry is waiting for. A reply for any other read is ignored. */
generation: number;
fetching: boolean;
error: boolean;
invalid: boolean;
observers: number;
}
export class QueryCache<T> {
#entries = new Map<string, Entry<T>>();
#listeners = new Set<() => void>();
private fetcher: Fetcher<T>;
private now: () => number;
private staleMs: number;
constructor(fetcher: Fetcher<T>, now: () => number, staleMs: number) {
this.fetcher = fetcher;
this.now = now;
this.staleMs = staleMs;
}
#entry(key: string): Entry<T> {
let entry = this.#entries.get(key);
if (!entry) {
entry = {
data: null,
updatedAt: 0,
generation: 0,
fetching: false,
error: false,
invalid: false,
observers: 0
};
this.#entries.set(key, entry);
}
return entry;
}
#isStale(entry: Entry<T>): boolean {
return entry.data === null || entry.invalid || this.now() - entry.updatedAt >= this.staleMs;
}
#fetch(key: string, entry: Entry<T>) {
const generation = ++entry.generation;
entry.fetching = true;
this.fetcher(key, (ok, data) => {
if (generation !== entry.generation) return; // replaced by a newer read
entry.fetching = false;
if (ok)
Object.assign(entry, {
data: data as T,
updatedAt: this.now(),
error: false,
invalid: false
});
else entry.error = true;
this.#notify();
});
}
/** A component starts reading `key`. It gets the cached copy, and a read if that copy is missing or stale. */
observe(key: string): void {
const entry = this.#entry(key);
entry.observers++;
this.refresh(key);
}
release(key: string): void {
const entry = this.#entry(key);
entry.observers = Math.max(0, entry.observers - 1);
}
/** Read again if stale and nothing is already on its way. One read per key. */
refresh(key: string): void {
const entry = this.#entry(key);
if (!entry.fetching && this.#isStale(entry)) this.#fetch(key, entry);
}
/** A write changed what `key` holds. Readers get a new read now; a read already out no longer counts. */
invalidate(key: string): void {
const entry = this.#entry(key);
entry.invalid = true;
if (entry.observers > 0) this.#fetch(key, entry);
this.#notify();
}
observed(): string[] {
return [...this.#entries]
.filter(([, entry]) => entry.observers > 0)
.map(([key]) => key)
.sort();
}
get(key: string): { data: T | null; status: Status } {
const entry = this.#entry(key);
const status: Status =
entry.data === null
? entry.error
? 'error'
: 'loading'
: entry.fetching
? 'refreshing'
: entry.error
? 'error'
: this.#isStale(entry)
? 'stale'
: 'fresh';
return { data: entry.data, status };
}
subscribe(listener: () => void): () => void {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
#notify() {
for (const listener of this.#listeners) listener();
}
} type Reply func(ok bool, data any)
type Fetcher func(key string, reply Reply)
type entry struct {
data any
updatedAt int
generation int
fetching bool
failed bool
invalid bool
observers int
}
// QueryCache holds one entry per key. A reply counts only if it answers the
// read the entry is waiting for.
type QueryCache struct {
entries map[string]*entry
fetch Fetcher
now func() int
staleMs int
}
func NewQueryCache(fetch Fetcher, now func() int, staleMs int) *QueryCache {
return &QueryCache{entries: map[string]*entry{}, fetch: fetch, now: now, staleMs: staleMs}
}
func (c *QueryCache) entry(key string) *entry {
e, ok := c.entries[key]
if !ok {
e = &entry{}
c.entries[key] = e
}
return e
}
func (c *QueryCache) stale(e *entry) bool {
return e.data == nil || e.invalid || c.now()-e.updatedAt >= c.staleMs
}
func (c *QueryCache) start(key string, e *entry) {
e.generation++
generation := e.generation
e.fetching = true
c.fetch(key, func(ok bool, data any) {
if generation != e.generation {
return // replaced by a newer read
}
e.fetching = false
if ok {
e.data, e.updatedAt, e.failed, e.invalid = data, c.now(), false, false
} else {
e.failed = true
}
})
}
// Observe: a component starts reading key.
func (c *QueryCache) Observe(key string) {
c.entry(key).observers++
c.Refresh(key)
}
func (c *QueryCache) Release(key string) {
if e := c.entry(key); e.observers > 0 {
e.observers--
}
}
// Refresh reads again if stale and nothing is already on its way.
func (c *QueryCache) Refresh(key string) {
if e := c.entry(key); !e.fetching && c.stale(e) {
c.start(key, e)
}
}
// Invalidate: a write changed what key holds.
func (c *QueryCache) Invalidate(key string) {
e := c.entry(key)
e.invalid = true
if e.observers > 0 {
c.start(key, e)
}
}
func (c *QueryCache) Observed() []string {
var keys []string
for key, e := range c.entries {
if e.observers > 0 {
keys = append(keys, key)
}
}
sort.Strings(keys)
return keys
}
func (c *QueryCache) Get(key string) (any, string) {
e := c.entry(key)
switch {
case e.data == nil && e.failed:
return nil, "error"
case e.data == nil:
return nil, "loading"
case e.fetching:
return e.data, "refreshing"
case e.failed:
return e.data, "error"
case c.stale(e):
return e.data, "stale"
}
return e.data, "fresh"
} The behavior these examples promiseChecked by 12 shared scenarios in TypeScript and Go
- Observing a key starts a read if the copy is missing or stale and none is in flight. A second observer of the same key starts nothing.
- A copy is stale 30 seconds after it arrived, or as soon as a write invalidates it. Refocusing the window refreshes stale keys that are on screen.
- Invalidating a key that is on screen starts a new read at once; a read already in flight no longer counts, and its reply is ignored. A key not on screen is marked and read when something shows it.
- A failed read keeps the last good copy and reports
error. Status isloading,fresh,stale,refreshing, orerror. - The server answers a read with the mail as it was when the read was sent. A write changes the mail when its reply succeeds.
Every expected result in the shared cases was produced by a separate model written from
these rules, kept beside the examples in model/cases.py, not copied from
either implementation. It models the component copies too.
Each component keeps a copyThe other build, in full
The shape of a useEffect that fetches into useState: every
component asks on mount, a reply sets whichever copy asked, and “mark as read” changes the
list’s own copy.
export class CopiedMail {
readonly server = new MailServer();
folder: Folder = 'inbox';
sidebar: Counts | null = null;
badge: number | null = null;
list: Listing | null = null;
listFor: Folder | null = null;
notice: 'mark-failed' | null = null;
constructor() {
this.server.read('counts', (ok, data) => ok && (this.sidebar = data as Counts));
this.#loadList('inbox');
}
#loadList(folder: Folder) {
this.server.read(`messages:${folder}`, (ok, data) => {
if (!ok) return;
this.list = data as Listing; // the last reply to arrive wins
this.listFor = folder;
});
}
open(folder: Folder) {
this.folder = folder;
this.#loadList(folder);
}
showBadge() {
this.server.read('counts', (ok, data) => ok && (this.badge = (data as Counts).inbox));
}
markRead(id: string) {
this.notice = null;
this.list = this.list?.map((item) => (item.replace('*', '') === id ? id : item)) ?? null;
this.server.markRead(id, (ok) => {
if (!ok) this.notice = 'mark-failed';
});
}
focus() {}
tick() {} Reading the TypeScriptPrivate fields and a closed-over generation
Each read captures generation when it starts. The reply compares it with
the entry’s current generation, so an invalidation that started a newer read makes the
older reply a no-op without canceling anything. #entries is private, so
components can only reach copies through get, with a status attached.
Reading the GoClosures and any
The Go cache stores any, because counts and listings share it, and the
client asserts the type when it reads. The fetcher gets a Reply closure, which
lets the tests hold replies and release them in any order, as the lab does.
Run it yourselfNo dependencies
Copy the complete TypeScript file and run node --experimental-strip-types mail.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:
module server-state-in-the-client
go 1.22
loaded, badge shares the read: inbox unread 2 · list m1* m2* m3 (fresh) · reads sent 2 after marking m1 read: inbox unread 1 · list m1 m2* m3 (fresh) · reads sent 4 31 s later: stale refresh failed, old copy kept: inbox unread 1 · list m1 m2* m3 (error) · reads sent 6
05 / Review the agent’s diff
“Mark as read is instant now.”
Waiting for a refetch before a row un-bolds does feel slow, and writing the change into the cache is a real technique. Read which copies this diff updates, and which it stopped invalidating.
06 / How it fails
A replica fails by being old, late, or alone.
Here is each way the mail client’s copies can go wrong, what the person reading mail sees, and what the cache build does.
| What goes wrong | What the reader sees | What the cache build does | Backed by |
|---|---|---|---|
| Slow | The list, then the same list marked refreshing. | Keeps the copy on screen while the new one loads. | Case “come back to a folder after the stale time” |
| Down | The last good copy, with a note that it could not refresh. | Keeps the data; status error. | Case “a refresh fails and the old data stays” |
| Wrong after a write | Every count agrees with the server. | Invalidates the counts and the folder the write changed. | Cases “mark a message read”, “mark a message in another folder” |
| Late and out of order | Updates shows Updates. | Stores each reply under its key; ignores a reply for a replaced read. | Cases “switch folders before the first list arrives”, “a write lands while an older read is still out” |
| Duplicated | Nothing; one request. | Shares one read between the sidebar and the tab badge. | Cases “a second reader of the counts…” |
| The write fails | A short message; the message stays unread. | Changes no copy. | Case “marking read fails” |
| Changed on another device | The old count, for up to 30 s. | Refetches stale keys on focus. Anything faster needs the server to push. | Checker question 5; authored beyond that |
Caching and invalidation covers the general problem, and Race conditions in UI the late reply outside a cache.
07 / Is it worth it?
You pay in keys and a dependency. Here is what it buys.
A cache adds a library or eighty lines, and a key for every read. Hold both builds up against the changes a mail client always gets.
| Change | Each component keeps a copy | One query cache |
|---|---|---|
| A second reader: a notification bell with the unread count | Another fetch, another copy that goes stale on its own. | Reads “counts”. No new request, and invalidation already covers it. |
| Replace fetch with a GraphQL client | Every component’s fetch changes. | Every query function changes. No difference here. |
| Change a rule: archiving also changes counts | Find every component that shows a count and refresh it. | The archive write invalidates “counts”. |
| A second team builds a search page | Their results go stale after a read, silently. | They add keys; “mark as read” can invalidate a key prefix and reach theirs. |
Before you move reads into a cache, decide what you will look at:
- Requests per page view, by endpoint. Duplicate reads of the same key should go to one.
- Time a shown count disagrees with the server after a write in this tab. The accepted result is one round trip.
- Reports of counts that “don’t update”, before and after.
This page did not run the client for real mail users, so it has no numbers to give you. The request count can be taken today from the network panel.
08 / Ask for it
Two prompts, two mail clients, one checker.
We sent two agents the same request for this mail client at the same time, both running Claude Sonnet. The shared prompt fixed the mailbox and the API. The architecture prompt added one cache keyed by what was asked, a 30-second stale time, one request per key, invalidation after “mark as read”, replies stored under their own key, and keeping the last good copy on a failed refresh. Then a script drove each build in Chromium, holding and failing requests in the browser’s own router.
| What the checker did | Plain prompt | Architecture prompt |
|---|---|---|
| Reads sent when the page opens | GET /api/counts 1×, GET /api/folders/inbox 1× | GET /api/counts 1×, GET /api/folders/inbox 1× |
| Mark “Invoice #204” read | server 1 unread; sidebar 1; title "(1) Mail" | server 1 unread; sidebar 1; title "(1) Mail" |
| Click Updates while the Inbox list is late | Updates was clicked last; the late reply put Inbox back on screen (m1, m2, m3) | Updates shows u1, u2 |
| Go back to Inbox while the server is failing | stayed on Updates with an error message | Inbox shows its last copy |
| Another device reads a message; come back 31 s later | no refetch; sidebar 2, server 1 | refetched; sidebar 1, server 1 |
The plain prompt got the headline right. After a message was marked read, its build fetched the counts again, and the sidebar and the tab title both said 1. The agent treated “the count changes when I read something” as part of the feature, which it is.
The builds split on everything the demo never shows. Held on the network, the plain build’s late Inbox reply took the page back to Inbox after the reader had clicked Updates, because each folder load renders whatever arrives. Another device read a message, and 31 seconds and a refocus later the plain build still said 2: nothing in it knew its copy had an age. When the server was failing, it stayed on the old folder with an error, which is fair; the architecture build showed the Inbox’s last good copy and said it could not refresh.
async function loadFolder(folder) {
try {
const messages = await getJSON(`/api/folders/${folder}`);
renderMessages(folder, messages);
clearError();
} catch (err) {
showError('Could not load messages for this folder. Try again shortly.');
}
} if (this._generations.get(key) !== generation) {
// A newer request for this key replaced this one; ignore this reply.
return this._entries.get(key);
} The lines that made the difference are the two the plain prompt never had reason to say: a reply is stored under the key it was asked for, and each cached copy is fresh for 30 seconds, then read again when shown or refocused. A copy without a key or an age is only right until the next thing happens.
How the runs were made and checkedOne run each, two checker runs
- Both agents received the prompts word for word, in fresh contexts, at the same time. The only differences were the Architecture block and the output folder.
- The architecture agent stalled after writing its last file and was asked, in the same conversation, to continue. It changed no file afterward; the checksums before and after match. It then wrote two pid and log files to the system temp folder, outside its own folder, while testing. Both are recorded in the run notes.
- The checker holds and fails requests in the browser’s own router, so the builds needed no test hooks. Its first run worded two plain verdicts badly (“Inbox messages shown under Inbox” for a page the reader had moved to Updates); the second run fixed the wording, not the measurement. Both runs are kept.
- One run of each prompt is a sample, not a measurement of a model.
09 / Hold it there
Make a private copy hard to add.
The next component that needs the counts is one fetch away from its own copy. Three
kinds of check keep reads going through the cache.
The framework’s own door
SvelteKit’s
loadis a cache with invalidation built in: afetchinside it registers a dependency, andinvalidate()“causes anyloadfunctions belonging to the currently active page to re-run if they depend on theurlin question” (the$app/navigationdocumentation in SvelteKit 2.70.3, the version in this repository). TanStack Query refetches stale queries when “the window is refocused” and when “new instances of the query mount” (Important Defaults). Use the framework’s door before building your own.An import rule an agent cannot argue with
Components get server data from query functions, never from the API client. This rule, run with dependency-cruiser 18.3 against a four-file fixture, flagged the sidebar that fetched its own counts and nothing else. Enforcement layer runs rules like this against real code.
.dependency-cruiser.cjs // .dependency-cruiser.cjs module.exports = { forbidden: [ { name: 'components-read-through-the-cache', comment: 'Components get server data from query hooks. Only src/queries may call the API client.', severity: 'error', from: { path: '^src/components/' }, to: { path: '^src/api/client\\.' } } ] };depcruise output error components-read-through-the-cache: src/components/FolderSidebar.js → src/api/client.js x 1 dependency violations (1 errors, 0 warnings). 4 modules, 3 dependencies cruised.A check on what actually happens
Import rules cannot see a missing invalidation. So test the claim the sidebar makes: after a write succeeds and the replies arrive, every count on screen equals the server’s. The lesson’s spec does it for every shared case, and the checker in section 08 does it to the recorded builds.
check-runs.mjs async 'mark a message read'(page) { await page.locator('[data-message-id="m1"]').click(); await wait(800); const shown = await read(page); const truth = await serverCounts(); const agrees = shown.unread.inbox === truth.inbox; const titleAgrees = shown.title.includes(`(${truth.inbox})`); return { shown, truth, verdict: `server ${truth.inbox} unread; sidebar ${shown.unread.inbox}${agrees ? '' : ' (stale)'}; title "${shown.title}"${titleAgrees ? '' : ' (stale)'}` }; },
Where this lives in React and SvelteEvery useQuery already follows this rule. The first mutation is where it gets tested.
Where it already is in your components
useQuery({ queryKey, queryFn }), useSWR(key, fetcher), and a
SvelteKit load are all this shape: a key, a copy, a rule for when it is
stale. When two components call useQuery with the same key and you see one request
in the network panel, that is deduplication you already rely on.
When you have to own it
The first mutation. A query library cannot know that marking a message read changes the
counts; the code that makes the write has to say so, with invalidateQueries in TanStack Query, mutate(key) in SWR, or invalidate('mail:counts') in SvelteKit. The same holds for a stale price you have
to show as stale, or a failed refresh you have to admit to: the library gives you the status,
and you decide what the page says.
Two readers of one key. The sidebar and the tab title use the same counts query, so one request feeds both. In SvelteKit the layout’s load holds the counts and depends on “mail:counts”.
import { useQuery } from '@tanstack/react-query';
type Counts = { inbox: number; updates: number };
async function getJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
return response.json() as Promise<T>;
}
// The sidebar and the tab title both read the unread counts. They ask by the
// same key, so the cache sends one request and both get the same copy.
export const countsQuery = {
queryKey: ['mail', 'counts'] as const,
queryFn: () => getJson<Counts>('/api/mail/counts'),
staleTime: 30_000
};
export function FolderSidebar() {
const counts = useQuery(countsQuery);
if (counts.isPending) return <p>Loading folders…</p>;
if (counts.isError && !counts.data) return <p role="alert">Could not load folders.</p>;
return (
<nav aria-label="Folders">
<a href="/mail/inbox">Inbox {counts.data.inbox || ''}</a>
<a href="/mail/updates">Updates {counts.data.updates || ''}</a>
{counts.isFetching && <span aria-live="polite"> Updating…</span>}
</nav>
);
}
export function useTitleBadge(): string {
const counts = useQuery(countsQuery);
return counts.data?.inbox ? `(${counts.data.inbox}) Mail` : 'Mail';
}
10 / Make the call
Cache what several places read; refetch what one place reads.
A settings page that loads once, shows one form, and saves has one reader and one writer. A fetch in the component is the shorter program, and it is fine. Reach for a cache when two components show the same server data, when a write in one place changes what another shows, or when a slow network makes order matter.
Reopen the decision when someone writes “refresh the sidebar” by hand after a mutation, when the same endpoint appears twice in the network panel for one page, or when a count needs a reload to be right.
Take it with you
Explain it without saying “server state”: “The page keeps copies of what the server told it, filed under what it asked. Copies get old. When I change something, I tell the page which copies are now wrong.” Then find the last mutation in your code and list every place that shows what it changed.
Paste into your next prompt, and fill in the blanks
The server owns <the data>. The browser keeps copies in one cache, keyed by what was asked: <the keys, such as ['mail', 'counts']>. Components read from the cache; none keeps its own copy. A copy is fresh for <30 seconds>, then stale; a stale copy stays on screen while it is read again. One request per key at a time. After <a write> succeeds, invalidate every key it changed: <the keys>. A reply is stored under the key it answers, and a reply for a replaced request is ignored. If a refresh fails, keep the last good copy and say so.
Connections to follow nextRelated lessons
- State ownership in a component tree is about state your components own; this lesson is about the state they only borrow.
- Unidirectional data flow tags a reply with its request id, the same move as the cache’s generation number.
- Polling, server-sent events, or WebSockets is what to add when other devices’ changes must show up faster than a stale time.
- Caching and invalidation is the general problem this cache solves one way.
- Local-first and sync is the design where the client’s copy becomes a real owner, and merging replaces refetching.