Caching Strategies

Client-Side Caching

0 of 7 complete

0%

Contents

Back|Caching StrategiesClient-Side Caching
1/7
21 min left
  1. Home
  2. System Design: Foundation
  3. Caching Strategies
  4. Client-Side Caching
Prerequisites
Browser Cachingrequired
Related Topics
Lazy LoadingWeb & Content DeliveryCachingCore FundamentalsContent Delivery Network (CDN)Load Balancing & ProxiesData Locality OptimizationData Replication & DistributionIn-Memory DatabasesDatabase Types & Storage
Previous lessonBrowser CachingNext lessonTime To Live (TTL)

System Design

1 of 7
  • Foundation
  • Intermediate
  • Advanced
  • Capstone
  • AI Engineering

    • Foundation
    • Data, RAG and Agents
    • Evaluation, LLM Ops and Security

    systemdesign.academy

    • Home
    • Glossary
    • Interview prep
    • Reviews
    • About
    • Privacy
    • Terms

    this free lesson is part of

    System Design Masterclass

    • 770 lessons, beginner to principal
    • 90 real interview systems taken apart step by step
    • An AI tutor beside every lesson that answers and draws
    • Lifetime access, every new lesson included

    $49 once, by PayPal

    See the System Design course

    Save your progress and get the interview checklist PDF

    System Design Interview Cheat Sheet: the 7 interview stages, the 10 points a scorecard grades, 12 classic systems. 18 pages. Free with an account.

    you just read a free lesson from

    System Design Masterclass

    • 770 lessons, beginner to principal
    • 90 real interview systems taken apart step by step
    • An AI tutor beside every lesson that answers and draws
    • Lifetime access, every new lesson included

    $49 once, by PayPal

    See the System Design course

    A Cache You Write, Not One the Browser Runs

    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.

    Two panels. The left, the HTTP cache, shows a response going through the browser deciding, ending in headers being the only control. Its key reads: keyed by the URL, you control via headers, expires on its own. The right, your client cache, shows an object going through your code deciding, ending in storage you chose. Its key reads: keyed by whatever you like, you control all of it, expires never unless you say.
    ComparisonFigure 1. The HTTP cache stores responses. This one stores your objects

    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.

    after “A Cache You Write, Not One the Browser Runs”

    This is where most answers get vague.

    The lesson that covers it: Browser Caching, in the System Design course.

    Plain English first, then the detail, then a quick check that you got it.

    See the System Design course

    Is Reading It Actually Expensive?

    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.

    A recorded terminal session. A table of payload sizes with stringify and parse timings alongside transfer times on two connection profiles. At 30 KB parse takes 0.05 ms against 25 ms to transfer on fast wifi. At 5,006 KB parse takes 8.23 ms against 840 ms on fast wifi and 3,477 ms on good 4G. The verdict section reports no crossover on either profile, with the cache 100 times cheaper on wifi and 414 times cheaper on 4G. A closing block names the real limits: one 60fps frame buys about 9.7 MB of parsing, localStorage stops at about 5 MB, and nothing expires.
    RecordedFigure 2. The lab was built to prove a point and disproved it

    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.

    A line chart in milliseconds against payload size in kilobytes. The fetch line rises from 25 ms at 30 KB to 840 ms at 5,006 KB. The JSON.parse line rises from 0.05 ms to 8.4 ms over the same range and appears almost flat against the fetch line.
    Chart

    Where to Put It, and How Much Room There Is

    Four hand-drawn boxes: in memory, sessionStorage, localStorage and IndexedDB, with the last highlighted. Labels beneath read gone on reload, gone with the tab, about 5 MB and sync, and hundreds of MB and async. A handwritten note reads: only the last one is both large and off the main thread.
    SketchFigure 5. Four options, and only one is both large and asynchronous

    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.

    A bar chart of typical usable megabytes per origin on desktop. sessionStorage is 5, localStorage is 5, IndexedDB is about 400 and the Cache API is about 400.
    ChartFigure 6. The two easy options stop at five megabytes

    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.

    Two panels. The left, localStorage, shows strings only going through synchronous, ending at about 5 MB. Its key reads: blocks the page yes, query by field no, good for small settings. The right, IndexedDB, shows structured objects going through asynchronous, ending at hundreds of MB. Its key reads: blocks the page no, query by field yes with indexes, good for real datasets.

    Nothing Expires, and the Tabs Disagree

    A sequence diagram with three lifelines: Tab A, localStorage and Tab B. Tab A loads the cart and holds it in memory. Tab B loads the same cart into its own copy. The user removes an item in Tab A, which writes the new cart. Tab B is still showing the removed item, noted as nothing told Tab B anything changed.
    SequenceFigure 9. Two tabs are two instances, with the same problem as two servers

    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.

    Five rows. cache:user means yesterday's shape with today's code, a crash. cache:v3:user means a new version is simply a new key. What happens to v2: nothing, until you delete it, and it holds quota. On start-up: delete every key that is not the current version. Why it matters: your code changes far more often than the data.
    FigureFigure 10. A client cache outlives your deploys, so yesterday's data meets today's code

    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.

    Show It First, Check It After

    A sequence diagram with three lifelines: the user, the client cache and the server. The user opens the page. The cache returns yesterday's data drawn immediately, noted as the screen is useful at zero milliseconds. At the same time it asks the server for the current version. The server returns today's data, and the parts that changed are updated in place.
    SequenceFigure 11. Show the stale copy at once, fetch the fresh one behind it

    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.

    A bar chart of milliseconds until something useful is on screen on good 4G. No cache is 754. Cache but block until fresh is also 754. Stale then revalidate is 8.
    ChartFigure 12. Caching and then waiting for the server gives you the uncached experience

    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.

    Two panels. The left, update after the response, shows a user edit going through waiting for the server, then updating the screen. Its key reads: feels slow but honest, on failure nothing to undo, complexity low. The right, optimistic update, shows a user edit updating the screen at once, then rolling back if it fails. Its key reads: feels instant, on failure must undo visibly, complexity real.

    Choosing, Excluding and Debugging

    A flowchart. Starting from something you want to keep on the client, it asks whether it must survive a page reload. If not, use a variable or your framework's query cache. If yes, it asks whether it is more than a handful of small values. If not, localStorage, since the synchronous cost is microseconds at that size. If yes, it asks whether another user of the device would see it. If yes, do not persist it, because a shared laptop is the threat model. If no, IndexedDB through a wrapper, with a schema version in the key.
    FlowchartFigure 15. Four questions, and the third is about a shared laptop

    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.

    Five exclusions. Access tokens, because any script on the page can read localStorage. Anything from another user, because a shared device is a real threat model. The only copy of user input, because eviction is silent and will lose it. Prices and balances, because showing a stale number is worse than a spinner. Permission decisions, because the server decides and the client displays.
    FigureFigure 16. Five things to keep out, and the first three are not performance decisions

    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.

    Quick Check

    Knowledge Check

    4 questions - Score 80% to pass

    Q1

    A colleague argues against caching 1 MB of JSON on the client because parsing it will jank the page. What does the measurement say?

    Q2

    Your application reads its cache on start-up but shows a spinner until the refetch completes. What have you built?

    Q3

    You ship a release that renames a field in an API response. Users who have the old shape cached crash. What prevents this?

    Q4

    Where should an access token be stored on the client?

    Figure 3. At five megabytes the parse takes 8 milliseconds and the download takes 840

    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.

    Five rows. The hypothesis: past some size, parsing costs more than fetching. Why it was plausible: parse is O(n) on the thread that draws the page. What was measured: 8.4 ms to parse 5 MB, 840 ms to fetch it. The verdict: refuted at every size a browser would store. What limits it instead: quota, and the fact that nothing expires. A closing line notes that the lab was built to prove a point and disproved it, so the lesson changed rather than the measurement.
    FigureFigure 4. The measurement disagreed with the reason it was taken

    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.

    ComparisonFigure 7. One of these blocks the thread that draws your page

    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.

    Three hand-drawn bars showing your origin's data, then disk filling up, then the browser clearing the least recently used origins, with the last highlighted and much shorter. A handwritten note reads: no warning, no event, and your code finds out by getting null.
    SketchFigure 8. The browser can empty your cache whenever the device runs short of space

    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.

    after “Nothing Expires, and the Tabs Disagree”

    There is more to this than one section can hold.

    It is taught in full in Local Cache, part of the System Design course.

    24 minutes, drawn step by step, so you can sketch it yourself afterwards.

    Look inside the System Design course
    ComparisonFigure 13. An optimistic update is a cache write you have promised to take back

    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.

    A bar chart of the percentage of the application still usable with no connection. No client cache is 0. Memory only is 35. Persisted is 80. Persisted plus a write queue is 100.
    ChartFigure 14. Persisting is the difference between an application that stops and one that keeps working

    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 flowchart for a user seeing old data. If a hard reload does not fix it, it is not the client cache, so look at the CDN, the app cache or the database. If it does, and the problem returns after navigating, check the schema version key and the start-up sweep. If it does not return, ask whether another tab was open: two tabs mean two copies, so listen for the storage event or use a BroadcastChannel. Otherwise it is an in-memory cache with no staleTime configured.
    FlowchartFigure 17. Four causes, and a hard reload splits them fastest

    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.

    Five rows with real product logos. TanStack Query offers staleTime and gcTime per query key, tagged memory. Apollo Client offers a normalised object cache rather than a response cache, tagged memory. Redux makes the store the cache with persistence as a plugin, tagged memory. The Cache API stores responses and is what a service worker reads from, tagged disk. idb and Dexie are the wrappers that make IndexedDB usable, tagged disk.
    ArchitectureFigure 18. Most client caching now happens inside a query library

    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.

    Four pairs of sentences. Parsing the cache is too slow becomes 8.4 ms for 5 MB against 840 ms to fetch it. We cache it so it loads instantly becomes only if you render before the refetch returns. localStorage is basically free becomes it is synchronous, every access blocks the paint. The cache will be there next time becomes eviction is silent, and likeliest on old devices.
    FigureFigure 19. Four sentences, and the first was this lesson's own hypothesis

    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.

    Six rows summarising the lesson. What it is: a cache your code keeps on the user's device. What it costs to read: 8.4 ms for 5 MB, measured, and not a reason. What actually limits it: quota, silent eviction, and no expiry at all. The tabs problem: two tabs are two instances and the user sees both. The pattern to use: render the stale copy, then revalidate behind it. The thing only it can do: keep working with no connection.
    FigureFigure 20. The only cache that can answer with no network, on hardware you do not own

    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.

    after “Choosing, Excluding and Debugging”

    The follow-up question usually starts here.

    Application Cache in the System Design course takes it from here.

    24 minutes that turn this section into an answer you can defend.

    See what the course covers $49 once, lifetime access