Beyond localStorage: The Five Caching Layers Every React Developer Should Know
Most React apps only scratch the surface of browser storage. This guide breaks down the five caching layers, from in-memory state to the HTTP cache, when to use each one, and how to connect them in a Next.js app.
The Mental Model Shift
Junior developers ask: "Where do I store this?"
Senior developers ask: "Which layer should own this data?"
These are different questions. The first leads to localStorage everywhere. The second leads to a layered architecture where each storage mechanism does one job well.
Modern frontends are distributed systems running inside the browser. Getting storage wrong means slow UIs, wasted network requests, and broken offline behavior.
The Five Storage Layers
Each layer solves a distinct problem. Use them together, not interchangeably.
| Layer | Speed | Capacity | Persistence | Best For |
|---|---|---|---|---|
| In-Memory State | Fastest | RAM | Lost on refresh | Live UI data, API responses |
| LocalStorage / SessionStorage | Fast | ~5–10 MB | Persistent / tab | Small preferences |
| IndexedDB | Medium | Hundreds of MB | Persistent | Large structured data |
| Cache API | Medium | GB scale | Persistent | Network responses, PWA |
| HTTP / Next.js Data Cache | Fastest | CDN-managed | Persistent | Static assets, server fetches |
1. In-Memory State: Your First Cache
Before reaching for the browser, cache in memory. TanStack Query and SWR both turn your data-fetching layer into a cache automatically.
TanStack Query: the two settings that matter:
import { useQuery } from '@tanstack/react-query'
const { data } = useQuery({
queryKey: ['product', id],
queryFn: () => fetch(`/api/products/${id}`).then(r => r.json()),
staleTime: 1000 * 60 * 5, // treat data as fresh for 5 minutes
gcTime: 1000 * 60 * 10, // keep unused data in memory for 10 minutes
})staleTime: how long before a background refetch is triggered. Set this to avoid hammering your API on every component mount.gcTime: how long unused cache entries live before garbage collection. Always keepgcTime >= staleTime.
Rule of thumb: most product data can tolerate staleTime of 2–5 minutes. User session data should be staleTime: 0.
2. LocalStorage and SessionStorage: Preferences Only
Both APIs are synchronous, meaning every read/write blocks the main thread. This is fine for tiny values, but a bad idea for large objects.
Use it for:
- Theme, locale, feature flags
- Dismissed banners or onboarding state
Never use it for:
- API response caches
- Auth tokens (use
httpOnlycookies) - Anything over ~50 KB
Always wrap access — storage can throw in private browsing or when quota is exceeded:
const storage = {
get<T>(key: string): T | null {
try {
const item = localStorage.getItem(key)
return item ? (JSON.parse(item) as T) : null
} catch {
return null
}
},
set(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
// quota exceeded or private mode, skip
}
},
}sessionStorage is identical but scoped to the tab lifetime. Use it for multi-step form state you don't want persisting across sessions.
3. IndexedDB: The Real Browser Database
When you need persistent, structured storage beyond a few kilobytes, IndexedDB is the answer. It is asynchronous, supports indexes and transactions, and can store binary data.
The raw API is verbose. Use Dexie.js instead:
import Dexie, { type Table } from 'dexie'
interface Draft {
id?: number
title: string
body: string
updatedAt: number
}
class AppDB extends Dexie {
drafts!: Table<Draft>
constructor() {
super('app-db')
this.version(1).stores({ drafts: '++id, updatedAt' })
}
}
export const db = new AppDB()
// write
await db.drafts.put({ title: 'My Post', body: '...', updatedAt: Date.now() })
// read with index
const recent = await db.drafts.orderBy('updatedAt').reverse().limit(10).toArray()Use IndexedDB for: offline-first apps, user drafts, large product catalogs, cached API payloads that survive refresh.
4. Cache API and Service Workers
The Cache API lets you intercept and store full HTTP request/response pairs. This is what powers offline support and instant reloads in PWAs.
There are three strategies. Pick the right one per resource type.
Cache-First: serve from cache, fall back to network. Best for static assets (fonts, images, JS bundles).
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(cached => cached ?? fetch(event.request))
)
})Network-First: try network, fall back to cache. Best for HTML and frequently updated APIs.
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then(res => {
const clone = res.clone()
caches.open('dynamic').then(c => c.put(event.request, clone))
return res
})
.catch(() => caches.match(event.request) as Promise<Response>)
)
})Stale-While-Revalidate: serve from cache right away, update the cache in the background. Best for avatars, product images, non-critical API data.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('swr-cache').then(cache =>
cache.match(event.request).then(cached => {
const networkFetch = fetch(event.request).then(res => {
cache.put(event.request, res.clone())
return res
})
return cached ?? networkFetch
})
)
)
})Use Workbox to set up these strategies without writing the boilerplate above.
5. HTTP Cache and Next.js Data Cache
The most overlooked layer. The browser and CDN handle this automatically when you set the right headers.
Key Cache-Control values:
Cache-Control: public, max-age=31536000, immutable → versioned static assets
Cache-Control: public, max-age=60, stale-while-revalidate=300 → API responses
Cache-Control: no-store → never cache (auth pages, user-specific data)
In Next.js App Router, every fetch call is a cache entry by default:
// cache for 1 hour, then revalidate on next request
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 3600, tags: ['products'] },
}).then(r => r.json())For non-fetch data (database queries, ORMs), use unstable_cache:
import { unstable_cache } from 'next/cache'
const getProducts = unstable_cache(
async () => db.select().from(products),
['products-list'],
{ revalidate: 3600, tags: ['products'] }
)On a data mutation, invalidate only the relevant tag instead of rebuilding the whole page:
'use server'
import { revalidateTag } from 'next/cache'
export async function createProduct(data: FormData) {
await db.insert(products).values(/* ... */)
revalidateTag('products') // only re-fetches what uses the 'products' tag
}Decision Guide
| Problem | Solution |
|---|---|
| Avoid refetching on every mount | staleTime in TanStack Query / SWR |
| Small UI preferences that survive refresh | localStorage |
| Multi-step form state within a tab | sessionStorage |
| Large structured data, offline-first | IndexedDB via Dexie |
| Offline pages, PWA, instant reloads | Cache API + Service Worker |
| Server-rendered page data | Next.js fetch with revalidate |
| Database query results on the server | unstable_cache + revalidateTag |
| Immutable versioned assets | Cache-Control: immutable |
Summary
No single storage API is the answer. A production frontend uses all five layers at once, with each layer owning the data it is best suited for.
The pattern that scales:
- TanStack Query / SWR owns live server state in memory
- LocalStorage owns tiny persistent preferences
- IndexedDB owns large structured data that survives refresh
- Service Worker + Cache API owns the network layer for offline and speed
- HTTP / Next.js Data Cache owns server-rendered and static content at the CDN edge
Think about your storage layer upfront. You will feel the difference every time you need to scale, debug, or support offline.