The last lesson was about a cache the browser operates on your behalf, driven entirely by response headers. This one is about a cache you build yourself, in storage you choose, holding your objects rather than responses.

The freedom is the point and it is also the problem. There is no Cache-Control here, no unless you build one, and no eviction until the browser runs out of room. Every mechanism that made the last lesson tractable is absent.
Before deciding what to put there, it is worth settling the objection everybody raises.
The usual argument against client is that deserialising costs more than people think. Getting a cached object back into usable JavaScript means parsing it, and JSON.parse is work on the same thread that draws the page.
That is a real concern, so this lesson's lab was written to demonstrate it. The script is at scripts/labs/clientcache/parse_cost.mjs.

At five megabytes, a payload larger than localStorage will even hold, the parse takes 8.4 milliseconds and the download takes 840 on fast wifi. There is no crossover at any size a browser would let you store.

V8 parses about 600 megabytes per second, which is far faster than most people assume. The accent line looks flat at this scale and is not. On good 4G the comparison is 414 times in the cache's favour.

The concern is genuine, the scale was wrong by about two orders of magnitude. If somebody rejects client caching because deserialising is slow, this is the number to bring.
The real reasons to be careful are storage limits and staleness, and both are correctness problems rather than performance ones.

The synchronous part of localStorage is the argument against it, not its size. Every read and write blocks the thread that draws the page, so a large localStorage read during start-up delays the first paint by exactly that long.

IndexedDB and the Cache API share one origin quota, commonly a share of free disk, so the real number depends on the user's machine and shrinks on a full device. Exceeding it does not warn you, it throws.

Use localStorage for a handful of small values where the synchronous cost is microseconds. Use IndexedDB, through a wrapper, for anything you would describe as data. The dividing line is roughly where you stop being able to name every key.

The eviction is silent, and it is most likely on the devices with the least free space, which are the oldest phones. Write every client cache read as though the value may be missing, because it may be, and not only on a first visit.

This is the local cache lesson again at the other end of the connection, and it is worse here, because the user can see both copies at once.
The storage event fires in other tabs when localStorage changes, and almost nobody listens for it. A BroadcastChannel does the same job more clearly. If your application can be open twice, the tabs need a way to tell each other something changed.

This is the same trick as a content hash in a filename, applied to data. It converts an upgrade problem into a naming problem, and it is the difference between a schema change being a migration you write and a schema change being a crash your users find.

Most data is unchanged most of the time. Showing yesterday's list and quietly correcting it is right far more often than it is wrong, and when it is wrong the correction arrives in the same second.

The middle bar is the common implementation: read the cache, but do not show it until the refetch confirms it. All of the benefit is discarded at that moment, and this is the single most frequent way client is built without any of it reaching a user.

Use optimistic updates where the action almost always succeeds and the undo is easy to show: a like, a checkbox, a reorder. Avoid them where failure is common or the rollback is confusing, which usually means anything involving money.

This is the one thing in the whole chapter that no server-side cache can provide, because it requires the data to already be on the device. Every other benefit here is a speed improvement over something that already worked.

A cached order history in IndexedDB survives logout unless you clear it, and clearing on logout only works if the user actually logs out. Library computers and shared tablets are common, and the default behaviour of every client cache is to keep the data.

The token rule is the one with teeth. A token in localStorage is one compromised dependency away from being exfiltrated, and an HttpOnly cookie is not readable by script at all. This is a well understood trade and it is still got wrong constantly.

Ask whether they had another tab open. It sounds like a joke question and it identifies the cause more often than any other, because the failure is invisible in logs, unreproducible in one window, and completely obvious once you know.

Find out what staleTime your query library is using before deciding you have no client cache. The default in most of them is zero, which means refetch on every mount, and teams often spend a sprint building a cache that was one configuration line away.

The second line wastes the most engineering. Teams build a persistence layer, wire it correctly, and then render a spinner until the refetch returns, which means every user still waits for the network and nobody can tell the cache exists.

Put a schema version in every key and delete the others at start-up. That one habit turns the two hardest problems here, a deploy meeting old data and old versions silently consuming quota, into something the naming scheme handles rather than something you have to remember.
4 questions - Score 80% to pass
A colleague argues against caching 1 MB of JSON on the client because parsing it will jank the page. What does the measurement say?
Your application reads its cache on start-up but shows a spinner until the refetch completes. What have you built?
You ship a release that renames a field in an API response. Users who have the old shape cached crash. What prevents this?
Where should an access token be stored on the client?