Cache Invalidation Strategies
TTL, tag-based on-demand, and event-driven invalidation — plus Next.js 15's use cache directive, cacheTag/cacheLife, thundering herd prevention, and the business logic behind choosing the right strategy.

Overview
Caching speeds up your application by storing the result of expensive operations. But cached data goes stale. Cache invalidation is how you decide when to discard old data and fetch fresh data.
There are four core strategies: time-based (TTL), tag-based on-demand, event-driven (webhooks), and stale-while-revalidate. Choosing the wrong one leads to users seeing outdated data — or worse, hammering your origin on every request.
The right strategy is always determined by one question: what is the business cost of serving stale data for N seconds?
How It Works
Every cache entry has a key and either a TTL (automatic expiry) or tags (explicit invalidation handles). Invalidation marks entries stale so the next read triggers a fresh fetch.
A useful mental model: a cache is a whiteboard. TTL puts an automatic eraser on a timer. Tag-based invalidation lets you wipe only the section labeled "products" — everything else stays. Stale-while-revalidate keeps you reading the whiteboard while someone quietly rewrites it in the background.
In Next.js App Router, caching is built into fetch(), unstable_cache, the use cache directive (Next.js 15+), and the revalidateTag / revalidatePath APIs.
Code Examples
1. Time-Based Invalidation (TTL)
Cache and re-fetch at most once per time window. Use when "slightly stale" is acceptable and the data doesn't require real-time precision:
// app/products/page.tsx
// Segment-level: all fetches in this route segment revalidate after 60s
export const revalidate = 60;
async function getProducts() {
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 }, // per-fetch TTL — overrides segment-level
});
if (!res.ok) throw new Error("Failed to fetch products");
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul>
{products.map((p: { id: string; name: string }) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}Per-fetch next: {revalidate} takes precedence over the segment-level export const revalidate. Use per-fetch when different data on the same page has
different freshness requirements — blog posts can be 5-minute stale while nav
items can be 1-hour stale.
2. Tag-Based On-Demand Invalidation
Tag fetch calls at read time; purge tags at write time. More surgical than TTL — data becomes fresh exactly when it changes, not on a timer:
// lib/data.ts
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
export async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: {
tags: [`product-${id}`, "products"], // tag at fetch time
revalidate: false, // cache indefinitely — purge explicitly
},
});
return res.json();
}
// For DB queries not going through fetch, use unstable_cache
export const getCachedFeaturedProducts = unstable_cache(
async () => {
return db.product.findMany({
where: { featured: true },
orderBy: { createdAt: "desc" },
take: 10,
});
},
["featured-products"], // cache key
{ tags: ["products", "featured-products"] }, // same tags work here
);// app/actions/update-product.ts
"use server";
import { revalidateTag } from "next/cache";
import { db } from "@/lib/db";
export async function updateProduct(
id: string,
data: { name: string; price: number },
) {
await db.product.update({ where: { id }, data });
// Surgical: only product-specific caches are invalidated
revalidateTag(`product-${id}`);
// Broader: also invalidate the product listing and featured list
revalidateTag("products");
}3. Next.js 15 — use cache Directive, cacheTag, and cacheLife
Next.js 15 introduces the use cache directive as a stable replacement for unstable_cache, with first-class cacheTag and cacheLife helpers:
// lib/data.ts — Next.js 15 (requires experimental.dynamicIO: true in next.config.ts)
import {
unstable_cacheTag as cacheTag,
unstable_cacheLife as cacheLife,
} from "next/cache";
// "use cache" marks this async function's result as cacheable
export async function getFeaturedProducts() {
"use cache"; // cache this function's return value
cacheLife("hours"); // preset: 1h revalidate, 1d expire
cacheTag("products", "featured"); // tags for on-demand invalidation
return db.product.findMany({
where: { featured: true },
take: 10,
});
}
// Custom cacheLife instead of a preset
export async function getProductById(id: string) {
"use cache";
cacheLife({ revalidate: 300, expire: 3600 }); // 5m stale window, 1h max age
cacheTag(`product-${id}`, "products");
return db.product.findUnique({ where: { id } });
}// next.config.ts — enable experimental dynamicIO for use cache
const nextConfig = {
experimental: {
dynamicIO: true, // enables use cache, cacheTag, cacheLife
},
};cacheLife presets: "seconds" (revalidate: 15, expire: 60), "minutes" (revalidate: 60, expire: 3600), "hours" (revalidate: 3600, expire: 86400), "days" (revalidate: 86400, expire: 604800), "weeks", "max" (no revalidation).
4. Event-Driven Invalidation — Webhooks
Invalidate exactly when data changes, triggered by an external system event:
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
import { createHmac } from "crypto";
export async function POST(req: NextRequest) {
const body = await req.text();
// Verify the webhook signature — HMAC-SHA256 is the Shopify/Stripe standard
const signature = req.headers.get("x-webhook-signature") ?? "";
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(body)
.digest("hex");
if (signature !== `sha256=${expected}`) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const { type, data } = JSON.parse(body);
// Map event types to cache tags
const tagMap: Record<string, string[]> = {
"product.updated": [`product-${data.id}`, "products"],
"product.created": ["products"],
"product.deleted": [`product-${data.id}`, "products"],
"inventory.updated": [`product-${data.id}`, "inventory"],
"post.published": [`post-${data.slug}`, "posts"],
"post.updated": [`post-${data.slug}`, "posts"],
};
const tags = tagMap[type] ?? [];
tags.forEach((tag) => revalidateTag(tag));
return NextResponse.json({ revalidated: tags.length, tags });
}Always verify webhook signatures before calling revalidateTag. An
unprotected revalidation endpoint is a denial-of-cache attack vector — anyone
can flush your entire cache on demand, causing a thundering herd on your
origin.
5. Thundering Herd Prevention
When a popular cache entry expires simultaneously for many users, they all trigger a cache miss at once — flooding the origin:
Cache expires at T=0
→ 1,000 concurrent users hit the endpoint
→ All 1,000 see a cache miss
→ 1,000 simultaneous origin requests
→ Origin crashes or rate-limitsThe three mitigations:
Staggered TTL — randomize expiry times to spread misses across a window:
// lib/cache-utils.ts
export function jitteredRevalidate(
baseSeconds: number,
jitterPercent = 0.15,
): number {
// Add ±15% random jitter — prevents synchronized expiry across cache nodes
const jitter = baseSeconds * jitterPercent;
return Math.floor(baseSeconds + (Math.random() * 2 - 1) * jitter);
}// Usage — each server generates a slightly different TTL
const revalidate = jitteredRevalidate(3600); // between 3060–3540s
const res = await fetch(url, { next: { revalidate } });Request coalescing (CDN-side) — most CDNs handle this automatically: when multiple requests arrive for an expired entry simultaneously, only one upstream request is sent. Configure it explicitly if your CDN requires it:
# Cloudflare: enabled by default with "Tiered Cache"
# Fastly: enabled with shielding / origin shield
# Vercel: built into Edge Network automaticallyOptimistic cache warm-up — proactively refresh the cache before it expires using a background job:
// scripts/warm-cache.ts — run before TTL expires in a cron job
export async function warmProductCache(productIds: string[]) {
await Promise.all(
productIds.map((id) =>
fetch(`${process.env.SITE_URL}/api/products/${id}`, {
// force a fresh fetch by bypassing the cache
headers: { "Cache-Control": "no-cache" },
}),
),
);
}6. Path-Based vs Tag-Based — Choosing the Right Scope
// ❌ Too broad — busts ALL data cached for /products, including unrelated fetches
revalidatePath("/products");
// ✅ Surgical — only product list data is invalidated
revalidateTag("products");
// ✅ Most surgical — only this specific product's data
revalidateTag(`product-${id}`);revalidatePath is useful as a fallback for routes that mix many data sources when you genuinely need all of them refreshed. Otherwise prefer tags.
Real-World Use Case
E-commerce admin dashboard with a CMS. Product listings are cached with TTL-based revalidation (revalidate: 300) for sustained traffic. When a merchant marks a product out-of-stock through the admin dashboard, a Server Action immediately calls revalidateTag("products") and revalidateTag("product-abc123") — customers see the correct inventory within seconds. The CMS posts webhooks to /api/revalidate on every publish — the homepage banner and blog index update instantly without a redeploy. High-traffic product pages use stale-while-revalidate so cache misses never introduce latency spikes. stale-if-error=86400 means a brief API outage doesn't bring down the entire storefront.
Common Mistakes / Gotchas
1. Using revalidatePath instead of tags. revalidatePath invalidates everything cached for that route. If the route fetches from five data sources, all five are busted even if one changed. Tags are surgical.
2. unstable_cache tags not matching fetch tags. If you tag a fetch() call with "products" and tag an unstable_cache DB query with "featured-products", calling revalidateTag("products") only invalidates the fetch — the DB cache is unaffected. Always use consistent, overlapping tags across all data access patterns.
3. Unprotected revalidation endpoints. A POST to /api/revalidate without signature verification lets anyone flush your cache. Always verify HMAC signatures or secret headers.
4. No thundering herd mitigation. A popular entry with a round-number TTL (revalidate: 3600) expiring simultaneously for thousands of users can DDoS your own origin. Add TTL jitter.
5. Testing invalidation in development. Next.js development mode bypasses most caching — revalidateTag appears to work because nothing was cached to begin with. Test invalidation behavior with next build && next start.
Summary
Cache invalidation strategy is a business decision: the cost of serving stale data determines how aggressively you must invalidate. TTL is the simplest — set it and forget it, accept some staleness. Tag-based on-demand is the most precise — data is fresh exactly when it changes. Event-driven webhooks decouple invalidation from application code and enable cross-system freshness. stale-while-revalidate eliminates latency spikes at the cost of a predictable staleness window. Next.js 15's use cache directive with cacheTag and cacheLife provides a first-class API for all of these patterns. Always protect revalidation endpoints with signature verification, and add TTL jitter to prevent thundering herds on high-traffic entries.
Interview Questions
Q1. What are the four core cache invalidation strategies and when do you use each?
TTL: set a fixed expiry duration — use when slightly stale data is acceptable and changes are predictable (blog posts, product descriptions). On-demand tag-based: purge specific cache entries exactly when the underlying data changes — use for data that must be fresh after writes (product inventory, pricing). Event-driven: trigger invalidation from external events via webhooks — use when the data source is a CMS, third-party API, or message queue that can push change notifications. Stale-while-revalidate: serve stale immediately, refresh in background — use to eliminate latency spikes on cache expiry for data where brief staleness is acceptable. Most production applications use all four simultaneously for different data types.
Q2. What is the thundering herd problem in caching and how do you prevent it?
The thundering herd occurs when a popular cache entry expires and many concurrent users simultaneously trigger cache misses — all firing origin requests at the same instant. The origin receives a flood it wasn't designed for and may rate-limit, crash, or return errors. The three mitigations: (1) TTL jitter — randomize the expiry time by ±10–15% so entries across different cache nodes don't expire simultaneously; (2) CDN request coalescing — when multiple requests arrive for the same expired entry, the CDN sends one upstream request and queues the others to receive the result; (3) proactive cache warming — a background job refreshes entries before they expire, so the cache is always warm when users request it.
Q3. What is the difference between revalidatePath and revalidateTag in Next.js?
revalidatePath("/products") invalidates all cached data associated with that URL — every fetch call, unstable_cache entry, and Server Component render cached for /products is discarded. It's a blunt instrument: if a product page fetches product data, related products, and navigation, all three are invalidated even when only the product changed. revalidateTag("product-123") is surgical — only cache entries explicitly tagged with "product-123" are invalidated. It works across pages and components: the product API route, the product detail page, and any Server Component that fetched tagged data all get invalidated in one call. Prefer tags for production applications; use revalidatePath only as a fallback when fine-grained tags aren't in place.
Q4. How does the use cache directive in Next.js 15 differ from unstable_cache?
unstable_cache is a function wrapper that memoizes the result of an async function across requests. It requires manually specifying the cache key, tags, and revalidation options. use cache is a React directive — placing it at the top of an async function body marks the function's return value as cacheable by the framework. cacheTag() and cacheLife() called inside the function set the tags and TTL declaratively. The key advantage: use cache works at the component level too (not just in data utilities) and integrates with React's component tree for more granular caching. Both use the same underlying Next.js Data Cache and respond to the same revalidateTag calls.
Q5. Why must you verify webhook signatures before calling revalidateTag?
An unprotected POST /api/revalidate endpoint lets any attacker flush your entire cache on demand. When all cache entries are invalidated simultaneously, every subsequent request is a cache miss — every request hits the origin. For high-traffic sites, this is effectively a self-inflicted DDoS: the origin is flooded with cold requests it can't serve at cache speeds, causing latency spikes, elevated error rates, or outright downtime. This is called a denial-of-cache attack. Webhook signatures (HMAC-SHA256 is the standard used by Stripe, GitHub, Shopify) cryptographically bind the payload to a shared secret — only legitimate senders know the secret and can produce a valid signature.
Q6. What is cacheLife in Next.js 15 and what do the presets represent?
cacheLife() sets the caching lifecycle for a use cache-marked function. It accepts a preset string or an explicit object { revalidate, expire }. revalidate is the stale-while-revalidate window — how long stale data is served while a background refresh is in-flight. expire is the absolute maximum age after which the entry is purged regardless of traffic. Presets: "seconds" (revalidate: 15, expire: 60) for near-real-time data, "minutes" (60/3600) for frequently-changing content, "hours" (3600/86400) for slowly-changing data, "days" (86400/604800) for editorial content, "max" for effectively static content. Choosing a preset is a business decision: it encodes how long you accept serving stale data before a user-initiated refresh, and the absolute ceiling beyond which freshness is mandatory.
HTTP Caching Strategies
Cache-Control directives, ETag conditional requests, stale-while-revalidate and stale-if-error, Vary headers, s-maxage vs max-age, and Next.js fetch caching — with the exact combinations that prevent stale-data bugs.
CDN Cache Purging
How CDNs cache at the edge, URL/tag/wildcard purging strategies, Cloudflare and Fastly APIs, origin shields and request coalescing for stampede prevention, Vercel Edge Network specifics, and tying purge calls to your deploy pipeline.