A Request Through Seven Caches: From React Query to Redis

Caching is often presented as a single architectural decision: put Redis in front of the database and make the system faster.

In a real web application, a request may encounter several caches before it reaches Redis. React Query may already have the result in memory. The browser may have a stored HTTP response. A CDN or Nginx may serve it at the edge. The application process may hold a local copy. Redis may prevent the final database query. The database itself may have the relevant pages in memory.

A web request passing through multiple cache layers before reaching the database

These layers are not interchangeable. Each one has a different owner, scope, lifetime, consistency model, and failure mode. Using all of them does not automatically make a system faster. It can instead create six stale copies of the same value.

The useful question is not “Where can I cache this?” It is:

Where can this data be reused safely, and which expensive work will that reuse eliminate?

Start with the path of one request

Suppose a user opens a product page and the UI requests GET /api/products/42.

Every hit closer to the user saves more downstream work. A React Query hit may avoid the network completely. An Nginx hit avoids application execution. A Redis hit still uses the network and application, but avoids a database query.

That does not mean the nearest cache is always best. The browser cannot safely share a response across users. One application instance cannot help another instance. Redis cannot eliminate internet latency. The correct layer depends on who can reuse the value.

The seven layers are solving different problems

LayerShared byTypical lifetimeBest at avoidingMain risk
React QueryComponents in one running appSeconds to one sessionDuplicate API requests and refetchesConfusing stale and cached data
sessionStorageOne browser tabUntil the tab closesRepeating tab-local workManual invalidation and synchronous access
localStorageSame origin in one browserAcross sessionsRe-fetching small, non-sensitive client dataLong-lived stale data and duplicated state
Browser HTTP cacheOne browser, according to HTTP rulesDefined by response headersTransferring unchanged responsesIncorrect cache headers or cache keys
CDN or NginxMany users or requestsSeconds to daysOrigin traffic and server executionLeaking personalized data or serving stale responses
Application memoryRequests reaching one processUntil eviction or restartSerialization and remote cache callsDifferent instances holding different values
RedisAll participating application instancesExplicit TTL or evictionRepeated database or external-service workNetwork dependency, hot keys, and invalidation

The database may also use a buffer pool, query-plan cache, and operating-system page cache. Those are important, but they are usually owned by the database rather than by application code. A warm database is not a reason to skip indexes, and an application cache is not a reason to ignore query design.

Layer 1: React Query is a server-state cache

React Query—now TanStack Query—keeps fetched server data in JavaScript memory and shares it between components using a query key.

const productQuery = useQuery({
  queryKey: ["product", productId],
  queryFn: () => fetchProduct(productId),
  staleTime: 60_000,
  gcTime: 10 * 60_000
});

Two timings matter:

A result can therefore be cached but stale. React Query may show the cached value immediately and refetch it in the background. This stale-while-revalidate behavior is excellent for product details, dashboards, and profiles where a short delay is acceptable.

React Query is not durable storage by default. A full reload removes its in-memory cache unless a persistence adapter is added. It is also not the correct home for purely local UI state such as whether a modal is open.

Advantages

Disadvantages

Use it when the UI needs lifecycle-aware server state. Do not add it merely to avoid storing a value in React state.

Layers 2 and 3: can localStorage and sessionStorage be caches?

Yes—but only when the stored value is a disposable copy of data whose authoritative source exists elsewhere.

If deleting a value causes data loss, it is storage, not a cache. If the application can discard it and reconstruct it from the server or another source, it can function as a cache.

localStorage

localStorage is scoped to an origin and survives browser restarts. It can be useful for small, relatively stable values such as feature metadata, a recently fetched reference list, or a persisted client-cache snapshot.

It has important limitations:

A cache entry needs more than a value:

type CacheEnvelope<T> = {
  version: number;
  expiresAt: number;
  value: T;
};

The version protects the application from old schemas after a deployment. expiresAt prevents a forgotten value from living forever. The application must treat parsing errors, quota errors, and missing entries as normal cache misses.

sessionStorage

sessionStorage has similar APIs and limitations, but its lifetime is tied to a browser tab. It is useful for tab-local, recoverable information: a short-lived search result, a multi-step flow snapshot, or data that should disappear when the tab closes.

It is not a server session, and it is not shared consistently across all tabs. It should not be used as proof of authentication or authorization.

When browser storage is the wrong cache

Avoid it when:

For larger structured data or asynchronous access, IndexedDB is usually a better browser-side option. The important architectural point remains the same: a browser cache belongs to one client and cannot reduce work for other users.

Layer 4: the browser HTTP cache

The browser HTTP cache stores complete responses according to standard HTTP semantics. Unlike localStorage, application JavaScript does not need to parse, timestamp, and invalidate every entry manually.

The server describes the policy through headers:

Cache-Control: public, max-age=60, stale-while-revalidate=300
ETag: "product-42-v17"
Vary: Accept-Encoding

After freshness expires, the browser can revalidate with If-None-Match. If the representation has not changed, the server returns 304 Not Modified without transferring the response body.

Different policies suit different resources:

no-cache does not mean “do not store.” It means the stored response must be revalidated before reuse. no-store is the directive that prevents storage.

Advantages

Disadvantages

A service worker can add another programmable browser cache. It is valuable for offline-first applications, but it also creates another invalidation boundary. It should be introduced for an explicit offline or network-resilience requirement, not as a universal performance switch.

Layer 5: CDN and Nginx response caching

A CDN or reverse proxy caches complete HTTP responses before requests reach the application. This is the first layer that can reuse one response across many clients.

Nginx can also perform short-lived microcaching. Caching a busy public endpoint for even one or two seconds can collapse a burst of identical requests into a single origin request.

proxy_cache api_cache;
proxy_cache_valid 200 30s;
proxy_cache_lock on;

proxy_cache_bypass $http_authorization;
proxy_no_cache $http_authorization;

The exact configuration depends on the system, but the safety questions are stable:

Advantages

Disadvantages

Use this layer for public, repeatable HTTP representations. Do not begin by caching authenticated endpoints and attempting to patch isolation later.

Layer 6: application-local memory

An application process can keep decoded objects or computed results in a bounded in-memory cache.

const products = new LRUCache<string, Product>({
  max: 1_000,
  ttl: 10_000
});

This is often called an L1 application cache. It is extremely fast: no network request, no remote serialization, and no dependency on Redis for a hit.

The trade-off appears as soon as the service scales horizontally. Ten instances now have ten independent copies. A write handled by instance A does not automatically invalidate instances B through J. Restarts also erase the cache, which means deployments can produce a temporary surge against Redis or the database.

Advantages

Disadvantages

Local memory works well for immutable metadata, compiled templates, parsed configuration, or a very short-lived L1 in front of Redis. It is dangerous for permissions, balances, inventory, and other values where a stale instance-local copy can cause an incorrect decision.

Layer 7: Redis as a shared application cache

Redis is commonly used as a distributed L2 cache. All application instances can share it, and entries can have explicit TTLs.

The usual pattern is cache-aside:

value = redis.get(key)

if value is missing:
    value = database.query(id)
    redis.set(key, value, ttl)

return value

Redis is still a network dependency. A hit is much faster than an expensive database operation, but slower than process memory. It also introduces eviction policies, connection limits, serialization costs, hot keys, and another failure mode.

Advantages

Disadvantages

Redis is most useful when many requests repeat expensive work and can tolerate a defined period of staleness. If a well-indexed database query is already fast and infrequent, Redis may add more complexity than value.

Caching strategies describe how reads and writes behave

The layer answers where a copy lives. The strategy answers how it is maintained.

Cache-aside

The application checks the cache, reads from the source on a miss, and populates the cache. It is simple and common, but a cache miss adds latency and concurrent misses can stampede the source.

Read-through

The application reads through a cache abstraction that loads missing values. This centralizes loading behavior but couples correctness to the cache library or service.

Write-through

Every write updates the source and cache as part of the request path. Reads are more likely to be fresh, but writes become slower and partial failures must be resolved.

Write-behind

Writes reach the cache first and are persisted asynchronously. This improves write latency but risks data loss and reordered updates. It is appropriate only when the product can tolerate those semantics.

Invalidation on write

The source is updated and related cache keys are removed. The next read repopulates them. This avoids constructing fresh cache values in the write path, but the invalidation must cover every affected key.

The hard problem is not storage; it is invalidation

Time-to-live is a safety net, not a complete consistency strategy.

Imagine a product update with copies in React Query, the browser HTTP cache, Nginx, application memory, and Redis. Deleting the Redis key does not remove the other four copies. Each layer needs an explicit freshness contract.

A practical design combines several techniques:

  1. Short TTLs for data where bounded staleness is acceptable.
  2. Versioned keys such as product:42:v17 when old representations must never be confused with new ones.
  3. Write-triggered invalidation for important updates.
  4. Event-driven invalidation when several services or instances hold copies.
  5. Conditional HTTP requests using ETags or modification times.
  6. Manual purge paths for operational recovery.

There is always a window between updating the source and invalidating every copy. The system design must state whether that window is acceptable instead of implying perfect consistency.

Three failure patterns appear repeatedly

Cache stampede

A popular key expires and thousands of requests miss simultaneously. Every request attempts the same database query.

Mitigations include request coalescing, Redis or proxy cache locks, soft expiration with background refresh, randomized TTL jitter, and serving a briefly stale value.

Cache penetration

Requests repeatedly ask for values that do not exist, so every request reaches the database. A short-lived negative cache can store “not found,” provided creation of the missing resource invalidates that entry.

Cache avalanche

Many keys expire together or the cache service fails, sending a sudden wave of traffic to the source. TTL jitter, capacity planning, circuit breakers, gradual warming, and database protection limits reduce the blast radius.

One value rarely belongs in every layer

Consider four examples.

Fingerprinted frontend assets

Use the browser HTTP cache and CDN with a long immutable lifetime. React Query, local storage, application memory, and Redis add no value.

Public product details

A reasonable path might use React Query for navigation, browser revalidation, a short CDN lifetime, and Redis for an expensive assembled view. An application L1 is justified only if Redis latency or hot-key load warrants it.

User permissions

Do not store them in localStorage as an authority. The UI may cache them for presentation, but the server must enforce authorization. Server-side caching should have a short, explicit lifetime and a reliable invalidation path.

Inventory during checkout

An approximate cached count can help the product page, but checkout must verify current availability against the authoritative inventory system. Faster stale data cannot replace a correctness boundary.

A decision framework for choosing a layer

Before adding a cache, answer these questions:

  1. What work is expensive? Network distance, database reads, third-party calls, computation, or serialization?
  2. Who can reuse the result? One component, one tab, one browser, one server process, one region, or every user?
  3. How stale may it be? Milliseconds, seconds, minutes, or effectively forever for versioned assets?
  4. What is the source of truth? Can the cached copy be deleted safely?
  5. How is it invalidated? TTL, revalidation, an event, a version change, or a purge?
  6. What happens on a miss? Is the source protected from a synchronized surge?
  7. What happens when the cache fails? Fail open, fail closed, serve stale, or degrade the feature?
  8. How will success be measured? Hit rate alone is insufficient; measure latency, origin load, eviction, staleness, and error rate.

If these answers are unclear, the system is not ready for another cache layer.

A sensible order of adoption

Start with the source: correct data modeling, appropriate indexes, efficient queries, and measured latency. Then add the cache closest to the expensive work and broad enough to achieve meaningful reuse.

A typical progression is:

  1. Cache immutable assets with HTTP headers and a CDN.
  2. Use React Query when the client repeatedly consumes server state.
  3. Add a shared Redis cache for demonstrated backend hotspots.
  4. Add Nginx or CDN response caching for safe, repeatable public responses.
  5. Add application-local L1 caching only for measured latency or hot-key pressure.
  6. Use browser persistence only when surviving reloads or supporting offline behavior is a real requirement.

This order is not universal, but it prevents “cache everywhere” from becoming the default architecture.

The main design principle

Caching moves work across time and space. It performs work once, stores the result somewhere closer to future consumers, and accepts an invalidation obligation in return.

React Query, localStorage, sessionStorage, the HTTP cache, Nginx, application memory, and Redis can all participate. They should not all participate automatically.

The best cache is the smallest number of well-owned layers that meet the system's latency, scale, and availability goals while keeping staleness understandable. Every additional copy should have a measurable benefit, a bounded lifetime, and a clear path back to the source of truth.