ENGINEERING ⏱️ 11 min read 📅 Oct 16, 2026

Web Caching from Frontend to Backend: TanStack Query, Redis & Eviction Strategies

Master full-stack caching in plain English. Learn frontend caching with TanStack Query, HTTP headers, backend caching with Redis, Cache-Aside patterns, and cache eviction strategies (LRU, TTL).

There is a famous saying in software engineering by Phil Karlton:

“There are only two hard things in Computer Science: cache invalidation and naming things.”

Almost every web application starts fast. But as users increase, databases get hammered with repetitive queries, frontend components trigger redundant network calls, and page load times degrade.

The single most effective tool to fix this is caching.

Caching is the art of storing a copy of expensive data in a fast temporary storage area so future requests return in single-digit milliseconds instead of seconds.

In this guide, we break down web caching from frontend to backend in simple, plain English—covering TanStack Query (React Query), HTTP Cache-Control, Redis backend caching, and cache eviction strategies.


The Restaurant Analogy: Caching in 60 Seconds

Imagine you visit a popular Italian restaurant:

flowchart TD
    subgraph WithoutCache [Without Caching - Expensive & Slow]
        C1[Customer asks for Menu] --> K1[Chef writes Menu by hand from memory]
        K1 --> C1
    end

    subgraph WithCache [With Caching - Instant & Cheap]
        C2[Customer asks for Menu] --> W2[Waiter grabs pre-printed Menu from the counter]
        W2 --> C2
    end
  1. Without Caching: Every time a diner asks for the menu, the waiter runs to the kitchen and asks the chef to write out today’s dishes by hand. The chef gets overwhelmed, and the diner waits 10 minutes just to see what to order.
  2. With Caching: The restaurant prints 50 copies of the menu in the morning and keeps them on the front counter. The waiter hands one over in two seconds. The chef never gets interrupted.

In web applications:


The Big Picture: Full-Stack Caching Architecture

Caching does not happen in just one place. A modern web application caches data at multiple checkpoints along the journey from user to database:

flowchart LR
    Browser["1. Browser Cache<br/>(HTTP Headers / Memory)"]
    TanStack["2. Client State<br/>(TanStack Query)"]
    CDN["3. Edge / CDN<br/>(Cloudflare)"]
    API["4. Backend Server<br/>(Node.js / Express)"]
    Redis["5. Cache Layer<br/>(Redis In-Memory)"]
    DB[("6. Primary Database<br/>(PostgreSQL/MongoDB)")]

    Browser --> TanStack
    TanStack --> CDN
    CDN --> API
    API <--> Redis
    API <--> DB

Let’s explore the two most crucial layers developers work with every day: Frontend Caching and Backend Caching.


1. Frontend Caching: TanStack Query (React Query)

The Problem With Naive useEffect Fetching

Before libraries like TanStack Query, most React developers fetched data like this:

// ❌ THE OLD WAY: Buggy, slow, redundant network calls
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  return <div>Hello, {user.name}</div>;
}

What goes wrong here?

  1. No Caching: If the user clicks between tabs or navigates away and returns, the screen flashes blank and shows "Loading..." while re-fetching the exact same data.
  2. Duplicate Requests: If two components on the screen need the current user’s profile, the browser fires two identical HTTP requests.
  3. Stale Data: If another tab updates the user’s name, the UI has no idea and remains outdated.

The TanStack Query Approach

TanStack Query acts as an intelligent in-memory cache for asynchronous server data in your frontend. It manages fetching, caching, synchronizing, and garbage-collecting server state automatically.

// ✅ THE MODERN WAY: Instant render from cache + background refresh
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// 1. Fetcher function
async function fetchUser(userId: string) {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) throw new Error('Network error');
  return res.json();
}

export function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, isError } = useQuery({
    queryKey: ['user', userId], // Unique cache key
    queryFn: () => fetchUser(userId),
    staleTime: 1000 * 60 * 5,   // Data is considered fresh for 5 minutes
    gcTime: 1000 * 60 * 15,      // Garbage collect unused cache after 15 minutes
  });

  if (isLoading) return <p>Loading...</p>;
  if (isError) return <p>Error loading user.</p>;

  return <div>Hello, {user.name}</div>;
}

The Two Most Important Knobs: staleTime vs gcTime

Many developers confuse these two settings:

PropertyWhat It MeansEveryday Analogy
staleTimeHow long the data is considered fresh. As long as data is fresh, TanStack Query will return it immediately without making a background network request.A carton of fresh milk. You drink straight from it without checking the store.
gcTime (formerly cacheTime)How long inactive data stays in browser memory before being garbage-collected (deleted).The trash collection schedule. Even when milk expires, you keep the carton in the fridge until garbage day.

[!TIP] Default Behavior: By default, TanStack Query sets staleTime: 0. This means data is instantly considered “stale”, so TanStack Query will serve the cached data immediately to keep the UI snappy, while silently triggering a background fetch to verify if the server has newer data. This is the Stale-While-Revalidate pattern!


Invalidating the Frontend Cache After Mutations

When a user updates their profile name, your cached user data is now out of date. You tell TanStack Query to evict or re-fetch it using queryClient.invalidateQueries():

function UpdateNameForm({ userId }: { userId: string }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newName: string) =>
      fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: newName }),
      }),
    onSuccess: () => {
      // 🔄 Invalidate the cache: TanStack Query automatically refetches the user
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });

  return (
    <button onClick={() => mutation.mutate('Alex')}>
      Change Name to Alex
    </button>
  );
}

2. HTTP Caching Headers: The Browser’s Native Cache

Before JavaScript even runs, web browsers have a built-in HTTP cache controlled by response headers sent by your backend or CDN.

Cache-Control: public, max-age=3600, stale-while-revalidate=60

Essential Cache-Control Directives Explained


3. Backend Caching: Redis & Node.js

On the backend, database queries are often the primary performance bottleneck.

Reading data from RAM (Memory) takes approximately 100 nanoseconds, while reading data from a database on SSD takes 10 to 50 milliseconds. That is roughly 100,000 times faster!

Why Redis?

Why not just store data in a global JavaScript object like const cache = {}?

  1. Multi-Instance Support: Modern backends run on multiple container replicas (e.g. 4 Node.js instances). A local JS object is not shared between servers. If User A updates their profile on Server 1, Server 2 still has stale data.
  2. Memory Leaks: A plain JS object grows indefinitely until your Node.js process crashes with JavaScript heap out of memory.
  3. Advanced Eviction & TTL: Redis comes with built-in time-to-live (TTL) and memory management algorithms.

The Cache-Aside (Lazy Loading) Pattern

The Cache-Aside pattern is the most common and robust backend caching architecture:

sequenceDiagram
    autonumber
    actor Client as User / Frontend
    participant API as Node.js API
    participant Redis as Redis Cache
    participant DB as PostgreSQL Database

    Client->>API: GET /api/v1/products/42
    API->>Redis: Check key "product:42"
    
    alt Cache Hit (Data Exists in Redis)
        Redis-->>API: Return cached JSON
        API-->>Client: 200 OK (Fast ~2ms)
    else Cache Miss (Data Not in Redis)
        Redis-->>API: null
        API->>DB: SELECT * FROM products WHERE id = 42
        DB-->>API: Product record
        API->>Redis: SETEX "product:42" 3600 (Cache with 1-hr TTL)
        API-->>Client: 200 OK (Slower ~45ms)
    end

Production Code Example: Node.js + Redis Cache-Aside

Here is a clean, practical implementation using Express and the official redis npm package:

// productService.ts
import { createClient } from 'redis';
import { db } from './db.js';

const redis = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
await redis.connect();

interface Product {
  id: string;
  name: string;
  price: number;
}

export async function getProductById(productId: string): Promise<Product> {
  const cacheKey = `product:${productId}`;

  // 1. Check Redis Cache
  const cachedData = await redis.get(cacheKey);
  if (cachedData) {
    console.log(`⚡ Cache HIT for key: ${cacheKey}`);
    return JSON.parse(cachedData) as Product;
  }

  // 2. Cache MISS: Query Primary Database
  console.log(`🐢 Cache MISS for key: ${cacheKey}. Fetching from Database...`);
  const product = await db.query('SELECT * FROM products WHERE id = $1', [productId]);
  
  if (!product.rows[0]) {
    throw new Error('Product not found');
  }

  const result = product.rows[0];

  // 3. Store in Redis with a 15-minute Time-To-Live (900 seconds)
  await redis.setEx(cacheKey, 900, JSON.stringify(result));

  return result;
}

// 4. Invalidate Cache when Product is updated
export async function updateProductPrice(productId: string, newPrice: number): Promise<void> {
  // Update database
  await db.query('UPDATE products SET price = $1 WHERE id = $2', [newPrice, productId]);

  // Evict / Invalidate the stale cache entry
  const cacheKey = `product:${productId}`;
  await redis.del(cacheKey);
  console.log(`🗑️ Evicted cache for key: ${cacheKey}`);
}

4. Cache Eviction Strategies: How Caches Decide What to Delete

A cache has limited memory. If you allocate 2GB of RAM to Redis, what happens when you try to store 2.1GB?

Without an eviction policy, Redis will reject new writes. To prevent this, caches use eviction algorithms:

flowchart TD
    MemoryFull[Cache Memory is Full] --> Policy{Eviction Policy}
    Policy -->|LRU| E1["Least Recently Used (LRU)<br/>Deletes items not accessed for the longest time"]
    Policy -->|LFU| E2["Least Frequently Used (LFU)<br/>Deletes items with the lowest access count"]
    Policy -->|TTL| E3["Volatile TTL<br/>Deletes items closest to expiring"]
    Policy -->|FIFO| E4["First In, First Out (FIFO)<br/>Deletes oldest written items regardless of use"]

1. LRU (Least Recently Used) — The Industry Standard

2. LFU (Least Frequently Used)

3. TTL (Time-To-Live) Expiration


5. Three Danger Zones Every Developer Must Avoid

1. The Thundering Herd (Cache Stampede)

2. Caching Private Data in Public Caches

3. Forgetting Cache Invalidation After Database Writes


Full-Stack Caching Cheat Sheet

Caching LayerWhere It LivesPrimary TechBest Used ForInvalidation Strategy
Client StateBrowser RAMTanStack Query, SWRActive UI data, user feeds, search resultsqueryClient.invalidateQueries() on mutation
HTTP Browser CacheBrowser Disk / RAMHTTP Cache-ControlStatic assets (JS, CSS, images), immutable filesContent hashing in filename (bundle.a1b2c.js)
Edge / CDNGlobal Server NodesCloudflare, FastlyPublic assets, static HTML, documentationWebhook purge on new deployment
Backend CacheServer Cluster RAMRedis, MemcachedExpensive DB queries, session tokens, rate limitsCache-Aside with TTL + explicit del on mutation

Self-Assessment & Interview Questions

1. What is the difference between staleTime and gcTime in TanStack Query?

Answer: staleTime determines when cached data is considered outdated; as long as data is fresh, TanStack Query will return it without firing background requests. gcTime defines how long inactive data remains in browser memory before being completely garbage collected.

2. Explain the Cache-Aside pattern. What are its main advantages?

Answer: In Cache-Aside, the application attempts to read from the cache first. If a Cache Miss occurs, it reads from the primary database, populates the cache with a TTL, and returns the response. The advantage is that the cache only stores data that is actually requested (saving memory), and if the cache goes down temporarily, the application gracefully falls back to the database.

3. What is a Cache Stampede and how do you prevent it?

Answer: A cache stampede occurs when a popular cached key expires, and thousands of concurrent requests simultaneously miss the cache and overwhelm the primary database. It is prevented using lock mechanisms (mutexes) to allow only one thread to regenerate the cache, or by adding random jitter to TTL durations.

4. Why is Redis preferred over local memory objects in Node.js?

Answer: In-memory JavaScript objects are isolated to a single process. In modern microservices or containerized clusters with multiple server instances, local objects become inconsistent and can cause memory leaks. Redis is a dedicated, ultra-fast distributed in-memory store that can be safely shared across all server instances.


Summary Checklist for Your Application

🛡️ Educational & Defensive Disclaimer: All technical concepts, code samples, and architectural breakdowns on SPIDERWORLD are published strictly for educational and defensive software engineering purposes. Never attempt to test or exploit vulnerabilities against systems or networks you do not own without explicit written authorization.

← Back to SPIDERWORLD Home
Venkatesh J

Venkatesh J

• Senior Software Developer

Passionate about full-stack architectures, web security, and explaining complex engineering concepts without the fluff.