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
- 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.
- 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 Chef is your primary database (PostgreSQL, MongoDB).
- The Printed Menu is your Cache (Browser memory, TanStack Query, or Redis).
- The Diner is your user.
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?
- 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. - Duplicate Requests: If two components on the screen need the current user’s profile, the browser fires two identical HTTP requests.
- 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:
| Property | What It Means | Everyday Analogy |
|---|---|---|
staleTime | How 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
max-age=3600: Store this response in the browser cache for 3,600 seconds (1 hour). For the next hour, clicking or refreshing loads instantly from disk cache.stale-while-revalidate=60: If the cached response is slightly expired (by up to 60 seconds), serve the old version immediately while fetching the fresh version in the background.no-cache: The browser can cache the file, but must check with the server (using anETag) before serving it. If unchanged, the server responds with a lightweight304 Not Modified.no-store: NEVER cache this. Used for banking dashboards, payment flows, and private authentication endpoints.
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 = {}?
- 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.
- Memory Leaks: A plain JS object grows indefinitely until your Node.js process crashes with
JavaScript heap out of memory. - 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
- How it works: Thinks of cache like a bookshelf. Every time an item is read, it moves to the very front. When space runs out, the book at the very back (the one nobody has touched in the longest time) gets tossed out.
- Best for: Most general web apps (e-commerce, blogs, social media), where recent content is far more likely to be accessed again.
2. LFU (Least Frequently Used)
- How it works: Tracks an access counter for each item. If an item was viewed 500 times, it stays safe. An item viewed only twice gets evicted first.
- Best for: Video platforms or music streaming where a few global hit tracks remain popular for months.
3. TTL (Time-To-Live) Expiration
- How it works: Every key is tagged with a lifespan (e.g., 10 minutes). Once the timer hits 0, the key expires automatically.
- Best for: Ephemeral data like OTP tokens, session tokens, and weather reports.
5. Three Danger Zones Every Developer Must Avoid
1. The Thundering Herd (Cache Stampede)
- The Trap: Suppose your homepage hero banner is cached in Redis with a 1-hour TTL. During a big sale, 10,000 users visit the site per minute. Exactly at 2:00 PM, the cache expires.
- The Crash: All 10,000 incoming requests experience a Cache Miss simultaneously. All 10,000 requests hit PostgreSQL at the exact same millisecond, crashing the database.
- The Fix:
- Jittered TTL: Instead of setting TTL to exactly 3600 seconds, add random noise (
3600 + Math.random() * 300). - Mutual Exclusion Lock (Mutex): Only allow one worker to query the database and repopulate the cache while other requests wait or receive stale data.
- Jittered TTL: Instead of setting TTL to exactly 3600 seconds, add random noise (
2. Caching Private Data in Public Caches
- The Trap: Setting
Cache-Control: public, max-age=3600on/api/account/billing. - The Nightmare: A shared proxy or CDN caches User A’s invoice and accidentally serves it to User B who visits next.
- The Rule: Always use
Cache-Control: private, no-storeon any route requiring authentication or containing personal identifiable information (PII).
3. Forgetting Cache Invalidation After Database Writes
- If you update a user’s role from
"member"to"admin", but forget to deleteuser:role:123from Redis, the user remains stuck as"member"until the TTL expires. Always pair your DB updates with cache evictions!
Full-Stack Caching Cheat Sheet
| Caching Layer | Where It Lives | Primary Tech | Best Used For | Invalidation Strategy |
|---|---|---|---|---|
| Client State | Browser RAM | TanStack Query, SWR | Active UI data, user feeds, search results | queryClient.invalidateQueries() on mutation |
| HTTP Browser Cache | Browser Disk / RAM | HTTP Cache-Control | Static assets (JS, CSS, images), immutable files | Content hashing in filename (bundle.a1b2c.js) |
| Edge / CDN | Global Server Nodes | Cloudflare, Fastly | Public assets, static HTML, documentation | Webhook purge on new deployment |
| Backend Cache | Server Cluster RAM | Redis, Memcached | Expensive DB queries, session tokens, rate limits | Cache-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
- TanStack Query Configured: Frontend server data is managed by query keys with appropriate
staleTime. - Mutations Invalidate Queries: Any POST/PUT/DELETE mutations trigger
invalidateQueriesto refresh UI state. - Static Assets Fingerprinted: JS and CSS files have unique build hashes with
Cache-Control: max-age=31536000, immutable. - Sensitive Routes Marked Private: Authenticated APIs return
Cache-Control: private, no-store. - Redis Cache-Aside Active: High-traffic database queries check Redis first and set sensible TTLs.
- TTL Jitter Applied: Popular backend cache keys include randomized expiration offsets to avoid thundering herds.
- Eviction on Update: Updating a record in the database immediately deletes the corresponding Redis key.