Client-side caching for API responses
Refetching the same data on every visit wastes time and bandwidth. HTTP cache headers, an in-memory cache and browser storage each solve a different part of the problem.
A user opens your product catalogue, clicks into a product and comes back. The app fetches the whole catalogue again. Nothing changed in those ten seconds, but the user waits and your API does the work twice.
Caching on the client removes that repeat work. There are three layers, and it pays to use them in this order.
1. Let HTTP do the caching
The browser already has a cache. It only needs to be told what it may keep.
Cache-Control: public, max-age=300, stale-while-revalidate=60
This response can be reused for five minutes. For one more minute after that, the browser may show the old copy while it fetches a fresh one in the background.
This layer costs no client code, and it also works for CDNs. Use it for anything that is the same for every user.
2. Keep data in memory during the session
Within a single visit, a data library such as TanStack Query or SWR keeps responses in memory. Two components asking for the same data share one request, and returning to a screen shows the cached data immediately while it revalidates.
If your app fetches with plain useEffect calls, adopting one of these libraries is often the largest single improvement.
3. Persist across visits when it helps
For data that changes slowly, such as a catalogue or a list of countries, you can keep a copy in browser storage with an expiry time.
const TTL = 15 * 60 * 1000; // 15 minutes
async function getCatalog() {
const cached = JSON.parse(localStorage.getItem("catalog") ?? "null");
if (cached && Date.now() - cached.savedAt < TTL) return cached.data;
const data = await fetch("/api/catalog").then((res) => res.json());
localStorage.setItem("catalog", JSON.stringify({ data, savedAt: Date.now() }));
return data;
}
Within fifteen minutes, the catalogue appears without a request.
Which storage for what
| Storage | Good for | Keep in mind |
|---|---|---|
| HTTP cache | Public, shared responses | Controlled by response headers |
| Memory | Data for the current session | Gone on reload |
localStorage |
Small, simple values | Synchronous, a few megabytes, text only |
| IndexedDB | Large or structured data | Asynchronous, more code to write |
| Cache API | Whole responses for offline use | Usually paired with a service worker |
The hard part: stale data
A cache is only useful if it is not wrong.
- Give everything an expiry. No entry lives forever.
- Invalidate after writes. When the user changes a record, drop the cached copy.
- Version your keys.
catalog:v2makes old entries disappear when the data shape changes. - Never store secrets in web storage. Access tokens and personal data in
localStoragecan be read by any script running on the page.
The result
Used together, these layers make repeat visits feel instant and reduce load on the API. Start with the HTTP headers. They are the cheapest win, and often the only one you need.