← Back to all articles
// Systems · Performance
HTTP Cache Next.js 15+ Vercel CDN Redis / Upstash Cloudflare

Caching Architecture Across CDN, Edge, and Application Layers

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.

Barnabas Waweru  ·  August 18, 2026  ·  14 min read
ansi · wordmark · caching
 ██████╗ █████╗  ██████╗██╗  ██╗██╗███╗   ██╗ ██████╗ 
██╔════╝██╔══██╗██╔════╝██║  ██║██║████╗  ██║██╔════╝ 
██║     ███████║██║     ███████║██║██╔██╗ ██║██║  ███╗
██║     ██╔══██║██║     ██╔══██║██║██║╚██╗██║██║   ██║
╚██████╗██║  ██║╚██████╗██║  ██║██║██║ ╚████║╚██████╔╝
 ╚═════╝╚═╝  ╚═╝ ╚═════╝╚═╝  ╚═╝╚═╝╚═╝  ╚═══╝ ╚═════╝ 

The Five Layers

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.
// The Mental Model

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.

Layer 1: Browser
Zero network cost. Controlled entirely by response headers. Works for assets, not for data that mutates server-side.
Layer 2: CDN Edge
Shared across all users. Cuts your origin load by orders of magnitude for public content. Cannot serve authenticated-user-specific data.
Layer 3: Next.js Data Cache
Server-side, per-deployment. Survives across requests and users. Tag-based invalidation on mutation is the killer feature.
Layer 4: Redis
For anything that is not an HTTP response: computed aggregates, external API results, short-lived tokens. Full control over key shape and TTL.
Layer 5: Origin
The source of truth. Slow, expensive, and load-sensitive. Every miss adds latency and cost. Protect it through the layers above.
Invalidation
The hardest part. Time-based TTL is a fallback. Tag-based and event-driven invalidation is what ships correct UX at scale.

Layer 1 + 2: HTTP Cache-Control

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.

The Essential Directives

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

The Two Patterns You Write Most Often

// Hashed static asset (JS/CSS bundle with content hash)
Cache-Control: public, max-age=31536000, immutable

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.

// Dynamic API response (catalogue, product list)
Cache-Control: public, max-age=10, s-maxage=3600, stale-while-revalidate=86400

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's Three-Header Model

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.

// app/api/catalog/route.ts
export async function GET() { return Response.json(await getCatalog(), { headers: { // Browser: 10 seconds "Cache-Control": "public, max-age=10", // Any downstream CDN: 60 seconds "CDN-Cache-Control": "public, s-maxage=60", // Vercel edge specifically: 1 hour + 24h SWR "Vercel-CDN-Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400", }, }); }

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.

// The s-maxage Trap

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.

Layer 3: Next.js Data Cache

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.

Critical Change in Next.js 15: fetch is Not Cached by Default

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.

// NOT cached (Next.js 15 default)
const data = await fetch("https://api/catalog");
// Opt-in: time-based ISR (max once per hour)
const data = await fetch("https://api/catalog", { next: { revalidate: 3600 } });
// Opt-in: cache indefinitely until explicitly invalidated
const data = await fetch("https://api/catalog", { cache: "force-cache" });
// Tag for on-demand invalidation
const data = await fetch("https://api/vendors", { next: { tags: ["vendors"] } });

unstable_cache for Non-fetch Data Sources

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.

// lib/data.ts
import { unstable_cache } from "next/cache"; import { db } from "@/lib/db"; export const getCachedVendors = unstable_cache( async () => db.select().from(vendors), ["vendors"], // cache key prefix { tags: ["vendors"], revalidate: 3600, // 1 hour TTL fallback } );

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.

Tag-Based On-Demand Invalidation

TTLs are a fallback. Tag the data on read, blow the tag on write. This gives you near-real-time freshness without polling.

// app/actions/vendor.ts
"use server"; import { revalidateTag, revalidatePath } from "next/cache"; export async function updateVendor(id: string, data: VendorInput) { await db.update(vendors).set(data).where(eq(vendors.id, id)); // Bust every fetch/unstable_cache tagged "vendors" revalidateTag("vendors"); // Also bust the Full Route Cache for this URL revalidatePath(`/vendors/${id}`); }

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.

// Tag Invalidation Does Not Purge the CDN

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.

Route Segment Config

Export a dynamic constant from any Page, Layout, or Route Handler to set a blanket caching policy for the whole route segment.

ValueBehaviorEquivalent 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

Layer 4: Redis / Upstash Cache-Aside

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.

The Standard Cache-Aside Pattern

// lib/cache.ts
import { redis } from "@/lib/redis"; // Redis.fromEnv() singleton export async function cached<T>( key: string, ttlSec: number, fetcher: () => Promise<T>, ): Promise<T> { const hit = await redis.get<T>(key); if (hit !== null && hit !== undefined) return hit; // HIT const fresh = await fetcher(); // MISS: compute await redis.set(key, fresh, { ex: ttlSec }); // backfill return fresh; }

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.

Thundering Herd: SET NX Lock

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.

// lib/cache-locked.ts
export async function cachedWithLock<T>( key: string, ttlSec: number, fetcher: () => Promise<T>, ): Promise<T> { const hit = await redis.get<T>(key); if (hit !== null) return hit; const lockKey = `lock:${key}`; const lockAcquired = await redis.set(lockKey, "1", { nx: true, // only set if key doesn't exist ex: 30, // lock expires in 30 seconds }); if (!lockAcquired) { // Another worker is computing. Poll or return stale. await new Promise(r => setTimeout(r, 100)); return (await redis.get<T>(key)) ?? (await fetcher()); } try { const fresh = await fetcher(); await redis.set(key, fresh, { ex: ttlSec }); return fresh; } finally { await redis.del(lockKey); } }

Key Namespace Design

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.

// Good: includes all dimensions
const key = `catalog:${locale}:${currency}:${categoryId}`; const key = `user:${userId}:profile`; const key = `exchange:${baseCurrency}:${quoteCurrency}`; const key = `daraja:token:${shortcode}`; // per merchant
// Bad: missing user dimension, serves wrong data
const key = `cart`; // whose cart? const key = `profile`; // whose profile?
Short-lived tokens
Daraja OAuth tokens are valid 3600 seconds. Cache them with a 3500s TTL. One token fetch per merchant per hour instead of one per API call.
Rate limiting
INCR + EXPIRE on a per-IP or per-user key. Upstash's sliding window rate limiter builds on this pattern natively.
Computed aggregates
Expensive GROUP BY queries, leaderboard slices, dashboard totals: compute once, cache for 60 seconds, serve 50,000 reads from memory.
Exchange rates
Fetch from Open Exchange Rates or similar once per hour per currency pair. Cache the result. Never pay per-request for a value that changes 60 times per day.

How a Single Request Flows

A request for /api/catalog from a new user in Nairobi, 45 minutes after the last cache miss:

1
Browser
Browser has no cached copy (new user, new session). Sends request to the nearest Vercel edge node.
2
CDN Edge HIT
Vercel edge in Johannesburg (or Frankfurt) has the response cached. s-maxage=3600 set 45 min ago. Serves in ~18ms. Origin sees zero traffic.
3
Response
Response arrives with x-vercel-cache: HIT. Browser caches for 10 seconds (max-age). Second request within 10s costs 0ms.

Now the same request 1 hour later, after a vendor updated their product:

1
Browser
Browser cache expired (max-age=10). Sends request to CDN edge.
2
CDN Edge STALE
s-maxage=3600 expired. CDN enters stale-while-revalidate window (86400s). Serves last known response immediately while revalidating in background.
3
Next.js Server
CDN background revalidation hits the Next.js function. revalidateTag("vendors") was called 5 min ago after the update. Data Cache is stale.
4
Neon MISS
Next.js fetches fresh data from Neon. Repopulates Data Cache with new TTL. Returns fresh response to CDN, which updates its edge copy.
5
User
User received the stale response instantly. Next request gets fresh data from the now-updated CDN cache. No wait, minimal staleness.
// stale-while-revalidate Is a UX Trade-Off

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.

Cloudflare Cache-Tag Purge

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.

Tagging Your Responses

// app/api/vendor/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) { const vendor = await getVendor(params.id); return Response.json(vendor, { headers: { "Cache-Control": "public, s-maxage=3600", // Cloudflare groups all responses with this tag "Cache-Tag": `vendor:${params.id},vendors`, }, }); }

Purging by Tag After a Write

// Cloudflare Cache-Tag purge (Zones API)
await fetch( `https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`, { method: "POST", headers: { "Authorization": `Bearer ${CLOUDFLARE_API_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ tags: [`vendor:${vendorId}`] }), } );

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.

Gotchas That Bite

Never cache user-private data in a shared layer

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.

Cache key must include all dimensions

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 does not purge the CDN

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.

Thundering herd on hot key expiry

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.

unstable_cache is correct for Next.js 14 and 15

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.

SWR delivers stale data after your user's mutation

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.

When to Use Each Layer

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.

Key Takeaways

  1. Cache headers are free infrastructure. 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.
  2. Next.js 15 does not cache fetch by default. Opt in explicitly with { next: { revalidate, tags } } or you pay for a fresh origin hit on every request.
  3. Tag-based invalidation beats TTL. Tag on read, blow the tag on write. You get near-real-time freshness with the performance benefits of caching.
  4. revalidateTag and CDN purge are two different systems. Call both when you need instant freshness at every layer after a write.
  5. A cache key that omits one dimension is a data leak waiting to happen. Include every variable that affects the result: user ID, locale, currency, tier.
  6. The thundering herd is not a theoretical problem. A hot key expiring under high concurrency can spike your origin load 10x in under a second. Use SET NX locks in Redis and stale-while-revalidate at the HTTP layer.
  7. Redis is for non-HTTP values. OAuth tokens, aggregates, rate limits, and computed results do not belong in Next.js Data Cache. They belong in Upstash Redis with explicit key namespacing and TTL.
  8. Never store user-private data in a shared cache layer. Set Cache-Control: private or no-store explicitly. Do not rely on the platform as your only safeguard.
About the Author
🏗️
// Author
Barnabas Waweru
Systems Architect · Founder of The Deep Family

Deep technical explorations of software architecture, AI systems, networking protocols, and the engineering decisions that power the systems we depend on every day.

All Articles
// Comments
0 comments
// Leave a comment
Your email is never published. Comments are moderated.
💬
No comments yet. Be the first to start the conversation.
// Share this deep dive

Send with a live card.