Skip to content
Back to Blog
CachingPerformanceReactNext.jsService WorkerIndexedDB

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.

6 min read

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.

LayerSpeedCapacityPersistenceBest For
In-Memory StateFastestRAMLost on refreshLive UI data, API responses
LocalStorage / SessionStorageFast~5–10 MBPersistent / tabSmall preferences
IndexedDBMediumHundreds of MBPersistentLarge structured data
Cache APIMediumGB scalePersistentNetwork responses, PWA
HTTP / Next.js Data CacheFastestCDN-managedPersistentStatic 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 keep gcTime >= 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 httpOnly cookies)
  • 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

ProblemSolution
Avoid refetching on every mountstaleTime in TanStack Query / SWR
Small UI preferences that survive refreshlocalStorage
Multi-step form state within a tabsessionStorage
Large structured data, offline-firstIndexedDB via Dexie
Offline pages, PWA, instant reloadsCache API + Service Worker
Server-rendered page dataNext.js fetch with revalidate
Database query results on the serverunstable_cache + revalidateTag
Immutable versioned assetsCache-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:

  1. TanStack Query / SWR owns live server state in memory
  2. LocalStorage owns tiny persistent preferences
  3. IndexedDB owns large structured data that survives refresh
  4. Service Worker + Cache API owns the network layer for offline and speed
  5. 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.