Caching Integration Guide
What is caching?
A five-layer stack — browser, CDN, Next.js, Redis, origin — each with its own key, TTL and invalidation path.
Push stable public data toward the user (long `s-maxage` + `immutable` fingerprinted assets) and keep volatile data in layers you can purge (Redis `DEL`, Next.js `updateTag`/`revalidateTag`). For codeAmani that's a 2G/3G strategy: `stale-while-revalidate` means the edge node near East Africa answers instantly and refreshes out of band, so the user never waits for a US origin. Upstash Redis caches the Daraja OAuth token (~50 min, under its 60 min expiry) and dedupes STK Push callbacks via `SET ... NX`. On Next.js 16 the framework layer is the stable `'use cache'` directive; the mature pattern is a long TTL backstop plus tag-based invalidation on the write path.
Six caching primitives
One request, five layers of cache — and the one hard problem that ties them together.
██████╗ █████╗ ██████╗██╗ ██╗██╗███╗ ██╗ ██████╗
██╔════╝██╔══██╗██╔════╝██║ ██║██║████╗ ██║██╔════╝
██║ ███████║██║ ███████║██║██╔██╗ ██║██║ ███╗
██║ ██╔══██║██║ ██╔══██║██║██║╚██╗██║██║ ██║
╚██████╗██║ ██║╚██████╗██║ ██║██║██║ ╚████║╚██████╔╝
╚═════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝Caching Integration Guide
Focus: a cache is a faster copy of a slower truth. The whole game is deciding how long the copy is allowed to lie and how you tell it to stop lying. This guide walks the five layers a codeAmani request passes through — browser, CDN/edge, Next.js, application (Redis), origin — and treats invalidation as the part that's actually hard.
Overview
Every cache is the same trade: serve a stored copy instead of recomputing, accepting that the copy may be stale. On a 2G/3G connection in Nairobi, that trade is not a micro-optimisation — it's the difference between a page that paints in 400 ms from a Mombasa edge node and one that round-trips 180 ms each way to a US origin for every byte. Caching is how an African-market product feels fast on a slow network.
There is no single cache. A request flows through a stack of them, each with its own TTL, its own key, and its own invalidation story:
The closer to the user a layer sits, the cheaper and faster the hit — but the harder it is to reach in and invalidate. A browser cache you cannot purge at all (only expire); a Redis key you can DEL in a millisecond. Design accordingly: put volatile data in layers you control, stable data in layers near the user.
Two famous truths frame the rest of this guide. Phil Karlton: "There are only two hard things in computer science: cache invalidation and naming things." And the operational corollary — a cache hit is a guess that the world hasn't changed. Every section below is about making that guess safely.
Official Documentation
| Source | URL | What it covers |
|---|---|---|
| MDN HTTP Caching | https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching | Cache-Control directives, ETag/If-None-Match, 304 flow |
| Next.js Cache Components (16) | https://nextjs.org/docs/app/getting-started/cache-components | cacheComponents, 'use cache', cacheLife/cacheTag, updateTag, PPR — the current model |
| Next.js classic caching | https://nextjs.org/docs/app/guides/caching-without-cache-components | Data Cache, Full Route Cache, fetch opt-in, revalidateTag/revalidatePath, legacy unstable_cache |
| Vercel CDN cache | https://vercel.com/docs/caching/cdn-cache | s-maxage, CDN-Cache-Control, Vercel-CDN-Cache-Control, x-vercel-cache |
| Cloudflare cache control | https://developers.cloudflare.com/cache/concepts/cache-control/ | Origin cache control, s-maxage, SWR, CF-Cache-Status, Cache-Tag |
| Upstash Redis (TS) | https://upstash.com/docs/redis/sdks/ts/getstarted | REST client (@upstash/redis 1.38.2), set with { ex } TTL, env config |
Layer 1 — HTTP caching (Cache-Control, ETag, SWR)
The HTTP cache is the foundation every other layer builds on. It is driven entirely by response headers — no library, no SDK. Get these right and the browser, the CDN, and any intermediary proxy all cooperate for free.
The directives that matter
| Directive | Meaning | Use for |
|---|---|---|
max-age=N | Fresh for N seconds in any cache (incl. browser) | Per-user data the browser may keep |
s-maxage=N | Fresh for N seconds in shared caches (CDN); overrides max-age there | CDN/edge TTL distinct from browser |
stale-while-revalidate=N | Serve stale up to N s while refreshing in background | Anything where instant > perfectly fresh |
no-cache | Store, but revalidate every time before reuse | HTML that changes but supports ETag |
no-store | Never store anywhere | Auth tokens, M-Pesa callbacks, PII |
private | Browser only, never a shared cache | Personalised responses |
public | Cacheable even with Authorization | Shared, non-sensitive assets |
immutable | Content will never change — skip revalidation entirely | Hashed/fingerprinted static assets |
The two patterns you'll write most:
# Fingerprinted asset (app-abc123.js) — cache forever, it can never change
Cache-Control: public, max-age=31536000, immutable
# Dynamic JSON — instant from cache, refresh in the background, edge TTL 60s
Cache-Control: public, max-age=10, s-maxage=60, stale-while-revalidate=300stale-while-revalidate: the African-market default
SWR is the single most valuable directive for a low-bandwidth audience. It decouples latency from freshness: the user always gets an instant response from cache, and the cache refreshes itself out of band. The cost of a slow origin is paid by a background fetch, never by the user staring at a spinner on a 2G connection.
The user at t=90s never waits for the origin even though the data was stale — they get the old copy instantly, and the next visitor gets the refreshed one.
ETag / If-None-Match: cheap revalidation
When content must be revalidated (no-cache, or a stale max-age), an ETag turns a full re-download into a tiny 304 Not Modified. The server hashes the body into an ETag; the browser echoes it back as If-None-Match; if unchanged, the server replies 304 with no body.
# First response
HTTP/1.1 200 OK
ETag: "v2-9f3a1c"
Cache-Control: no-cache
# Browser revalidates
GET /api/profile → If-None-Match: "v2-9f3a1c"
# Unchanged — body skipped, bytes saved
HTTP/1.1 304 Not ModifiedOn a metered Kenyan data plan, a 304 is the difference between paying for 40 KB of JSON and paying for ~200 bytes of headers. In Next.js Route Handlers you can set this directly:
// app/api/profile/route.ts
export async function GET(req: Request): Promise<Response> {
const profile = await getProfile();
const etag = `"v2-${hash(profile)}"`;
if (req.headers.get("if-none-match") === etag) {
return new Response(null, { status: 304, headers: { ETag: etag } });
}
return Response.json(profile, {
headers: { ETag: etag, "Cache-Control": "private, no-cache" },
});
}Never cache secrets. Auth tokens, M-Pesa credentials, and PII responses get
Cache-Control: no-store. Vercel's CDN already refuses to cache any response carryingSet-CookieorAuthorization, but be explicit — don't rely on the platform to save you.
Layer 2 — CDN / edge caching (Vercel + Cloudflare)
The CDN is a shared cache sitting in dozens of cities, including ones close to East African users. It keys on the URL (plus any Vary headers) and obeys s-maxage. This is where a single origin render gets amortised across thousands of visitors.
Vercel
Vercel's CDN caches a function/SSR response when the Cache-Control header contains s-maxage (with optional stale-while-revalidate). It also honours targeted headers so you can give the edge, downstream CDNs, and the browser different TTLs in one response:
// app/api/catalog/route.ts — browser 10s, downstream CDN 60s, Vercel edge 1h
export async function GET() {
return Response.json(await getCatalog(), {
headers: {
"Cache-Control": "public, max-age=10",
"CDN-Cache-Control": "public, s-maxage=60",
"Vercel-CDN-Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
},
});
}Inspect the x-vercel-cache response header to see what happened: HIT, MISS, STALE (served stale, revalidating), or PRERENDER. That header is your first debugging stop when a page "won't update" — a HIT means you're looking at the cache, not the origin.
Vercel strips
s-maxageandstale-while-revalidatefrom the header sent to the browser if you don't also setCDN-Cache-Control, so the browser only seesmax-age. It also does not currently supportstale-if-errororproxy-revalidatefor server-side caching.
Cloudflare
Cloudflare honours origin Cache-Control/s-maxage (Origin Cache Control is on by default) and supports stale-while-revalidate fully asynchronously — expired requests return stale content immediately with a background refresh. Read the CF-Cache-Status header (HIT, MISS, EXPIRED, REVALIDATED, UPDATING, BYPASS) to diagnose behaviour.
Cloudflare's killer feature for invalidation is the Cache-Tag response header: attach tags to a response, then purge every response carrying a tag in one API call — tag-based invalidation at the CDN layer (Enterprise; Cache Reserve / Workers KV give similar control on other plans).
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
Cache-Tag: catalog, prices, vendor-42# Purge everything tagged "prices" across the whole edge in one shot
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
--data '{"tags":["prices"]}'Layer 3 — Next.js caching (two models)
Next.js layers several caches on top of HTTP. As of Next.js 16 there are two ways to drive them, and which one you're in changes the API you reach for:
- Cache Components (the current default direction) — opt in with
cacheComponents: trueinnext.config.ts. You mark cached work with the stable'use cache'directive and control it withcacheLife()/cacheTag(); everything else is dynamic-by-default and streamed via<Suspense>. This flag replaces the oldexperimental.ppr/experimental.dynamicIO/experimental.useCacheand enables Partial Prerendering. - Classic model (still valid when Cache Components is off) —
fetchopt-in caching plus the legacyunstable_cache, invalidated withrevalidateTag/revalidatePath. This is what most existing codeAmani apps run today and still works in 16.
Both models share the same underlying caches:
| Cache | Scope | Lives | Invalidated by |
|---|---|---|---|
| Request Memoization | Single render pass | Memory | Automatic (per request) |
Data Cache / 'use cache' | Across requests/users | Server | revalidate/cacheLife (time), revalidateTag/updateTag/revalidatePath (on-demand) |
| Full Route Cache | A whole rendered route | Server | Data Cache revalidation, redeploy |
| Router Cache | Client-side nav | Browser memory | Time, router.refresh(), server action |
Request memoization
Within one render, multiple fetch() calls to the same URL hit the network once — React/Next dedupes them. For non-fetch data access (an ORM, the Supabase client), wrap it in React's cache() to get the same dedupe:
import { cache } from "react";
export const getVendor = cache(async (id: string) => db.vendor.findUnique({ where: { id } }));Classic: Data Cache + time-based revalidation
fetch is not cached by default (unchanged since Next.js 15) — opt in. Time-based revalidation gives you ISR-style behaviour: serve cached, regenerate after N seconds.
// Cached, regenerates at most once per hour (ISR)
const data = await fetch("https://api/...", { next: { revalidate: 3600 } });
// Explicitly cache a one-off fetch
const stable = await fetch("https://api/...", { cache: "force-cache" });
// For non-fetch (DB) calls — legacy in 16, still works when Cache Components is off
import { unstable_cache } from "next/cache";
export const getCachedVendor = unstable_cache(
async (id: string) => db.vendor.findUnique({ where: { id } }),
["vendor"], // key prefix
{ tags: ["vendor"], revalidate: 3600 },
);
unstable_cacheis now legacy. Next.js 16 replaces it with the'use cache'directive; the API still ships and works (its docs page is titled "unstable_cache (legacy)"), but new code should prefer'use cache'— especially once Cache Components is enabled.
Next.js 16: the 'use cache' directive
With cacheComponents: true, annotate a file, component, or function with 'use cache' and it is cached automatically — the cache key is derived from the function's arguments and closure, so there is no manual key array. Pair it with cacheLife() (TTL) and cacheTag() (invalidation handle):
// next.config.ts → { cacheComponents: true }
import { cacheLife, cacheTag } from "next/cache";
async function getVendors() {
"use cache";
cacheTag("vendors"); // invalidation handle
cacheLife("hours"); // built-in profile: minutes | hours | days | weeks | max
return db.select().from(vendors);
}cacheLife also takes an inline shape — cacheLife({ stale: 3600, revalidate: 7200, expire: 86400 }). You cannot read cookies()/headers()/searchParams inside 'use cache' (pass them as arguments, or use 'use cache: private' for compliance cases where you must).
Tag-based, on-demand invalidation — the good part
Time-based revalidation is a guess at how often data changes. Tag-based invalidation is precise: tag the data when you read it, then blow that tag away when you write. Next.js 16 splits this into two functions:
"use server";
import { revalidateTag, updateTag, revalidatePath } from "next/cache";
// read-your-own-writes: caller sees fresh data THIS request (Server Actions only)
export async function addVendor(form: FormData) {
await db.vendor.create({ /* ... */ });
updateTag("vendors"); // immediate expiry, next request blocks for fresh data
revalidatePath("/vendors"); // also bust the Full Route Cache for this URL
}
// background SWR: mark stale, refresh on next visit (Server Actions AND Route Handlers)
export async function onWebhook() {
revalidateTag("vendors", "max"); // NOTE: two-arg in 16 — "max" = stale-while-revalidate
}Signature change in Next.js 16.
revalidateTagnow takes a second argument:revalidateTag(tag, profile). The recommended"max"gives stale-while-revalidate (serve stale, refresh in background);revalidateTag(tag, { expire: 0 })forces immediate expiry for webhooks. The single-argumentrevalidateTag(tag)is deprecated — it still works if TypeScript errors are suppressed but may be removed. For read-your-own-writes inside a Server Action, prefer the newupdateTag(tag)(single-arg, immediate, Server-Action-only).revalidatePathis unchanged (optional'page' | 'layout'second arg). All of these mark data stale on the server — they do not purge the Vercel/Cloudflare CDN edge, which you invalidate separately (Layer 2).
Layer 4 — Application caching (Upstash Redis)
When the thing you're caching isn't an HTTP response — a computed result, a DB aggregate, a short-lived token — you reach for an application cache. Upstash Redis is the codeAmani default: serverless, REST-based (works from Edge runtime and Vercel functions with no TCP connection), pay-per-request.
The package: @upstash/redis. Env: UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN.
// lib/cache.ts
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
/** Cache-aside: try cache, fall back to origin, backfill with a TTL. */
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 with TTL
return fresh;
}The TTL on set(..., { ex }) is your safety net: even if you forget to invalidate, the key self-destructs. Always set a TTL — an unexpired key is a future stale-data bug.
The two M-Pesa cases this exists for
Daraja OAuth token caching. The Daraja token is valid for ~1 hour. Don't re-OAuth on every STK Push — cache it just under its lifetime so you always refresh before expiry:
async function darajaToken(): Promise<string> {
return cached("daraja:token", 3000, async () => { // 50 min < 60 min TTL
const res = await fetch(`${DARAJA_BASE}/oauth/v1/generate?grant_type=client_credentials`, {
headers: { Authorization: `Basic ${basicAuth()}` },
});
return (await res.json()).access_token as string;
});
}STK Push idempotency. Store the CheckoutRequestID the moment the STK Push returns, using set with NX so a duplicate callback is a no-op. This is the dedupe key from the webhooks guide, backed by Redis instead of a unique DB constraint:
// Returns true only the FIRST time we see this CheckoutRequestID
const first = await redis.set(`mpesa:cri:${id}`, "1", { nx: true, ex: 86400 });
if (!first) return; // duplicate callback → already processed, ignoreCache invalidation — the actual hard problem
Everything above is easy. Invalidation is where systems rot. The core tension: the longer the TTL, the better the hit rate — and the longer wrong data is served. There is no universally correct TTL; there is only a deliberate choice per data type.
TTL vs tag-based: pick by whether you know when data changes
| Time-based (TTL) | Tag/event-based | |
|---|---|---|
| Idea | Expire after N seconds, hope that's often enough | Invalidate the instant the data actually changes |
| Staleness | Up to the full TTL | Near-zero |
| Best for | Data that drifts predictably (exchange rates, leaderboards) | Data with a clear write/mutation event (a vendor edits a price) |
| Cost | Wasted refreshes / stale windows | Must wire every writer to invalidate |
| codeAmani layer | CDN s-maxage, fetch revalidate/cacheLife, Redis ex | updateTag/revalidateTag(tag,"max"), Cloudflare Cache-Tag, Redis DEL on write |
The mature pattern combines them: a long TTL as a backstop plus tag invalidation for correctness. Tags handle the known changes; the TTL guarantees nothing is stale forever even if an invalidation is missed (and one always eventually is).
Practical rules
- Match the layer to volatility. Volatile, must-be-correct data → app cache you can
DEL. Stable, public data → push it to the CDN with a longs-maxage. - Invalidate on write, not on a timer, when you can. A
revalidateTagin the mutation path beats a 30 s TTL: fresher and fewer wasted refreshes. - Fingerprint immutable assets.
app-[hash].jswithimmutable, max-age=31536000is never invalidated — you change the URL instead. The cleanest invalidation is the one you never have to do. - Beware the stampede. When a hot key expires, every concurrent request misses and hammers the origin at once.
stale-while-revalidate(HTTP) and a single-flight lock (RedisSET NXguard around the recompute) both prevent it. - Never cache what you can't afford to be stale — and never put a secret in a cache at all.
Debugging: which layer is lying?
A stale page is almost always one layer holding an old copy. Walk the stack from the user inward:
| Symptom | Check | Header / signal |
|---|---|---|
| Browser shows old page | DevTools → Network → Disable cache | Cache-Control, Age |
| Edge serving stale | Response header | x-vercel-cache (HIT/STALE) · CF-Cache-Status |
| Next.js route won't update | Did a mutation call revalidateTag/revalidatePath? | — |
| Redis returns old value | redis.ttl(key) — is it expiring? Did the writer DEL? | TTL value |
A HIT anywhere means you're being served a cached copy — that's the layer to invalidate, not the origin.
codeAmani notes
- Caching is the 2G/3G performance strategy. On a slow Kenyan connection, perceived speed = how rarely you make the user wait for the origin. Default dynamic responses to
s-maxage+stale-while-revalidateso the edge node in/near East Africa answers instantly and refreshes in the background. Fingerprint andimmutable-cache every static asset so repeat visits cost zero bytes. - Upstash Redis is the app-cache default — REST-based, so it works from Edge runtime and serverless functions without connection pooling. Use it for the Daraja OAuth token (cache ~50 min, refresh before the 60 min expiry) and STK Push idempotency (
SET mpesa:cri:<id> NX EX 86400to dedupe callbacks — same dedupe key as the webhooks guide, Redis-backed). - Never cache secrets or PII. Daraja credentials, Clerk session tokens, M-Pesa callbacks →
Cache-Control: no-store, neverNEXT_PUBLIC_*, never in a CDN-cacheable response. Vercel refuses to cacheSet-Cookie/Authorizationresponses — rely on it as a backstop, not a policy. - Invalidate on the mutation path. When a vendor edits a listing, the same Server Action that writes the DB calls
updateTag("vendors")(read-your-own-writes) — orrevalidateTag("vendors", "max")from a webhook Route Handler — and, where applicable,DELs the matching Redis key. On Next.js 16, rememberrevalidateTagnow needs the two-arg form; the barerevalidateTag("vendors")is deprecated. Long TTLs are only a backstop against missed invalidations. Cache-Controlenv caveat: TTLs live in code/headers, not secrets — but the Upstash REST URL/token are secrets (UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKEN):.env.locallocally, Vercel env vars in prod.