← Architecture
Frontend applications Copies of the server’s truth

Server state in the client

Fetched data is a replica.

Every useQuery, every SWR hook, every SvelteKit load function hands your component a copy of something the server owns. The copy starts going out of date the moment it arrives. Let’s ask an agent for a mail client and watch an unread count after you read the message it is counting.

TypeScriptGoOne mailbox, two builds, two recorded agent runs.

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.

Who owns each piece of the mail client
StateOwnerWhy
Which messages exist, and which are readThe serverOther devices read and change them too.
Unread counts, the folder listsThe server; the client holds cached copies under keysSeveral components show them. One copy per key keeps them the same.
How old a copy may getThe cache’s stale timeA product decision, written once, not left to each component.
Which copies a write made wrongThe code that makes the writeOnly it knows the write touched the counts and one folder.
Which folder is selectedThe URLClient 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.

Server state

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

 

01/ 04
Mark as read, component copies

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.

TypeScriptReading
mail.ts
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();
	}
}
GoAlongside
main.go
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 is loading, fresh, stale, refreshing, or error.
  • 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.

copies.ts · each component keeps a 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:

go.mod
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.

The agent’s pull request

“Mark as read is instant now: the mutation writes the new read state into the cached list instead of waiting for a refetch. One request fewer per click. Tests pass.”

// MessageList.tsx
			const read = useMutation({
			  mutationFn: markRead,
			(removed)  onSuccess: () =>
			(removed)    queryClient.invalidateQueries({ queryKey: ['mail', 'counts'] }),
			(added)  onSuccess: (_, id) =>
			(added)    queryClient.setQueryData(['mail', 'messages', folder], (list) =>
			(added)      list?.map((m) => (m.id === id ? { ...m, read: true } : m))
			(added)    ),
			});
			
You are reviewing this change. What do you do?

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.

Failure modes of a cached copy
What goes wrongWhat the reader seesWhat the cache build doesBacked by
SlowThe 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”
DownThe 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 writeEvery 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 orderUpdates 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”
DuplicatedNothing; one request.Shares one read between the sidebar and the tab badge.Cases “a second reader of the counts…”
The write failsA short message; the message stays unread.Changes no copy.Case “marking read fails”
Changed on another deviceThe 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.

The same four changes, made to each build
ChangeEach component keeps a copyOne query cache
A second reader: a notification bell with the unread countAnother fetch, another copy that goes stale on its own.Reads “counts”. No new request, and invalidation already covers it.
Replace fetch with a GraphQL clientEvery component’s fetch changes.Every query function changes. No difference here.
Change a rule: archiving also changes countsFind every component that shows a count and refresh it.The archive write invalidates “counts”.
A second team builds a search pageTheir 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 found, run 2026-09-23
What the checker didPlain promptArchitecture prompt
Reads sent when the page opensGET /api/counts 1×, GET /api/folders/inbox 1×GET /api/counts 1×, GET /api/folders/inbox 1×
Mark “Invoice #204” readserver 1 unread; sidebar 1; title "(1) Mail"server 1 unread; sidebar 1; title "(1) Mail"
Click Updates while the Inbox list is lateUpdates 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 failingstayed on Updates with an error messageInbox shows its last copy
Another device reads a message; come back 31 s laterno refetch; sidebar 2, server 1refetched; 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.

public/app.js · plain prompt
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.');
  }
}
public/js/cache.js · architecture prompt
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.

  1. The framework’s own door

    SvelteKit’s load is a cache with invalidation built in: a fetch inside it registers a dependency, and invalidate() “causes any load functions belonging to the currently active page to re-run if they depend on the url in question” (the $app/navigation documentation 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.

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

ReactAlready in your code
FolderSidebar.tsx
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

Take the mail client into your editor. Add “archive”, and decide which keys it has to invalidate before you write it.

Back to architecture →