Five caching layers stand between your database and your user. Most developers configure one. The ones who configure all five ship apps that cost 90% less to run and respond in under 50ms globally.
██████╗ █████╗ ██████╗██╗ ██╗██╗███╗ ██╗ ██████╗ ██╔════╝██╔══██╗██╔════╝██║ ██║██║████╗ ██║██╔════╝ ██║ ███████║██║ ███████║██║██╔██╗ ██║██║ ███╗ ██║ ██╔══██║██║ ██╔══██║██║██║╚██╗██║██║ ██║ ╚██████╗██║ ██║╚██████╗██║ ██║██║██║ ╚████║╚██████╔╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝
Every request your user makes passes through up to five distinct caches before hitting your origin. Each layer has its own key space, TTL model, and invalidation story. Misconfigure one and you either pay too much to compute or serve stale data to the wrong person.
User (browser)
│
│ Layer 1: Browser Cache
│ HTTP Cache-Control headers · max-age · ETag/If-None-Match
│
▼
CDN / Edge (Cloudflare, Vercel Edge Network)
│
│ Layer 2: CDN Cache
│ s-maxage · Vercel-CDN-Cache-Control · CF-Cache-Status
│ Cache-Tag purge API · geo-distributed, ~40ms P95
│
▼
Next.js Server (Vercel Function / container)
│
│ Layer 3: Next.js Data Cache
│ fetch({ next: { revalidate, tags } }) · unstable_cache
│ revalidateTag · revalidatePath · ISR
│
▼
Application Layer (your Node.js process)
│
│ Layer 4: Redis / Upstash
│ Cache-aside · get-or-set · SET NX lock
│ Computed aggregates · OAuth tokens · rate limits
│
▼
Origin (Neon, Supabase, external API)
│
│ Layer 5: The ground truth
│ Every cache miss ends here. Keep this < 200ms.
A cache is a faster copy of a slower truth. Each layer above is faster and cheaper than the one below. The goal is to answer as many requests as possible without reaching the origin. A well-tuned stack can serve 95% of catalogue traffic from the CDN edge, 4% from the Next.js data cache or Redis, and only 1% from the database.
HTTP caching is driven entirely by response headers. No library required. The browser, Vercel's edge network, and Cloudflare all read the same headers and cooperate automatically. Get these right and you have caching at two layers for free.
| Directive | Effect | Scope |
|---|---|---|
max-age=N |
Fresh N seconds in any cache, including browser | Browser + CDN |
s-maxage=N |
Fresh N seconds in shared (CDN) caches only. Overrides max-age there. | CDN only |
stale-while-revalidate=N |
Serve stale up to N seconds while refreshing in the background | Browser + CDN |
no-cache |
Revalidate before every reuse (pair with ETag) | Browser + CDN |
no-store |
Never store anywhere. Use for auth tokens, PII, webhook callbacks. | Browser + CDN |
private |
Browser only. Never stored in a shared CDN cache. | Browser only |
public |
Cacheable even with an Authorization header | Browser + CDN |
immutable |
Content will never change. Skip revalidation for hashed static assets. | Browser + CDN |
One year TTL. The hash in the filename guarantees this URL never serves stale content. Immutable tells the browser to skip the revalidation request entirely.
Browser keeps it fresh for 10 seconds. CDN keeps it for 1 hour. During the 24-hour SWR window, a stale copy is served instantly while the cache refreshes in the background. The user never waits; they might see data up to 1 hour old on the first request after expiry.
Vercel lets you give the edge, downstream CDNs, and the browser different TTLs in a single response. This is one of the most useful features for tiered caching.
Vercel strips s-maxage from the browser-facing header automatically when CDN-Cache-Control is also set. Inspect the x-vercel-cache response header to debug: it returns HIT, MISS, or STALE. Cloudflare uses CF-Cache-Status for the same purpose.
s-maxage is for CDN and shared caches only. The browser ignores it and obeys max-age. If you set only s-maxage with no max-age, the browser treats the response as uncacheable and re-requests it on every navigation. Set both when you want different TTLs per layer.
Next.js 15 ships four server-side cache mechanisms. Two matter for daily work: the Data Cache (persists across requests and users) and Request Memoization (single render pass only). Next.js 16 introduced Cache Components behind a flag, but the patterns here cover the stable, widely deployed model.
Before Next.js 15, fetch was cached by default. In 15+, the default changed to 'auto no cache'. Opt in explicitly or you get fresh origin hits on every request.
Supabase client, Drizzle/Prisma queries, computed aggregates: none of these use fetch. Wrap them in unstable_cache. The "unstable" prefix means the API surface may change between minor versions, not that it is broken. It has been the recommended pattern since Next.js 14.
The key prefix array plus the serialized arguments form the full cache key. Include every dimension that affects the result: user ID for per-user data, locale for translated content, currency for pricing. A key that omits a dimension serves one user's data to another.
TTLs are a fallback. Tag the data on read, blow the tag on write. This gives you near-real-time freshness without polling.
revalidateTag marks data stale. The next request regenerates it; it does not eagerly prefetch. For external data changes, wire a webhook handler that calls revalidateTag the moment the upstream system writes. You get cache freshness without any TTL polling.
revalidateTag invalidates Next.js's server-side Data Cache. It does not automatically purge Vercel's or Cloudflare's CDN edge cache. For CDN-level tag purging, call Vercel's Cache Purge API or Cloudflare's Cache-Tag purge endpoint separately. These are two different systems with different invalidation paths.
Export a dynamic constant from any Page, Layout, or Route Handler to set a blanket caching policy for the whole route segment.
| Value | Behavior | Equivalent to |
|---|---|---|
'auto' (default) |
Cache as much as possible without blocking dynamic components | Per-fetch opt-in |
'force-dynamic' |
Every request hits the origin. No caching. | cache: 'no-store' on every fetch |
'force-static' |
Prerender at build time. cookies/headers return empty. | Static export with ISR revalidation |
'error' |
Error if any dynamic data is used. Strict static only. | getStaticProps equivalent |
When what you are caching is not an HTTP response, Redis is the tool. Computed aggregates, M-Pesa Daraja OAuth tokens (valid 60 minutes), exchange rate snapshots, rate limit counters: none of these fit neatly into fetch or unstable_cache. Upstash Redis is REST-based and works from Vercel Edge runtime and serverless functions without TCP connection pooling.
Get on read, set on miss. Simple. The risk is the thundering herd: when a hot key expires and 500 concurrent requests all miss at once, they all call the fetcher simultaneously. That doubles your origin load at the worst moment.
On cache miss, use SET NX (set if not exists) to let only one worker recompute while the others wait or return a slightly stale value. This trades one heavy origin hit for one lightweight Redis lock check.
A Redis key that omits a required dimension will serve one user's data to another. Build keys from every variable that affects the result.
A request for /api/catalog from a new user in Nairobi, 45 minutes after the last cache miss:
Now the same request 1 hour later, after a vendor updated their product:
SWR serves a stale copy instantly while refreshing in the background. The user sees outdated data for one request. This is usually the right call for catalogue and listing pages. It is the wrong call for anything where the user just mutated data and expects immediate confirmation. After a write mutation, call revalidateTag and redirect to a fresh page load rather than letting SWR deliver the old state.
If you proxy through Cloudflare, add Cache-Tag headers to group cached objects. Then purge by tag via the API when the underlying data changes. This is CDN-level tag invalidation, separate from Next.js's revalidateTag.
This purges every cached response tagged vendor:${vendorId} across all of Cloudflare's 300+ edge nodes globally. Tag purge propagates in under 150ms. Compare this to waiting for the TTL to expire.
Responses containing Set-Cookie are refused by Vercel's CDN automatically, but set Cache-Control: private or no-store explicitly. Do not rely on the platform as your only safeguard. In Redis, namespace per-user keys (user:<id>:profile) and never use a global key for user-specific results.
A Redis key or Next.js cache-key prefix that omits a parameter will serve one user's data to another. The silent failure here is severe: no errors, just wrong data being served confidently. Build cache keys from every variable that affects the result: user ID, locale, currency, category ID, plan tier.
revalidateTag marks the Next.js server-side Data Cache stale. Vercel's CDN edge cache has a separate TTL controlled by s-maxage or Vercel-CDN-Cache-Control. If you need both caches invalidated immediately after a write, call the Vercel Purge API (or Cloudflare Cache-Tag purge) alongside revalidateTag.
When a popular Redis key expires, every concurrent request misses and slams the origin simultaneously. Use stale-while-revalidate at the HTTP layer and a SET NX lock at the Redis layer to let exactly one request recompute while the others wait or serve stale. A thundering herd on a 50,000 req/s endpoint can double your database load in under a second.
A stable 'use cache' directive is available behind a flag in Next.js 15 and was stabilized in Next.js 16 under the cacheComponents flag. Until you are on 16 with Cache Components enabled, unstable_cache is the right tool. The "unstable" prefix signals the API surface may shift between minor versions, not that it is unreliable in production.
If a user updates their profile and the next page load hits the CDN's stale-while-revalidate window, they see their old data. Fix this by calling revalidatePath in the Server Action and redirecting after the mutation so the browser requests a fresh page rather than reading its cached copy.
| What you are caching | Layer to use | Why |
|---|---|---|
| Hashed JS/CSS bundles, images | Browser (Cache-Control: immutable) | Content never changes at this URL. Browser caches permanently. |
| Public catalogue / product listings | CDN edge (s-maxage + SWR) + Next.js Data Cache | Shared across all users. CDN serves globally at edge latency. |
| User-specific page data | Next.js Data Cache (per-user tag) | Private: CDN cannot serve this. Tag invalidation on write. |
| OAuth tokens (Daraja, Google) | Redis with TTL = token lifetime minus 60s | Short-lived, not an HTTP response. Redis is the right store. |
| Computed aggregates (dashboard totals) | Redis cache-aside + 60s TTL | Expensive query, shared result, not HTTP-response shaped. |
| Rate limit counters | Redis INCR + EXPIRE | Sub-millisecond atomicity required. Redis is the only fit. |
| Auth session, PII, webhook payload | No caching (Cache-Control: no-store) | Security-sensitive. Never cache. |
| External API response (exchange rates) | Redis or Next.js unstable_cache + long TTL | Rate limits on the upstream API make caching mandatory. |
s-maxage plus stale-while-revalidate on public API routes costs nothing and can absorb 95% of your catalogue traffic at CDN level without touching the origin.{ next: { revalidate, tags } } or you pay for a fresh origin hit on every request.revalidateTag and CDN purge are two different systems. Call both when you need instant freshness at every layer after a write.SET NX locks in Redis and stale-while-revalidate at the HTTP layer.Cache-Control: private or no-store explicitly. Do not rely on the platform as your only safeguard.