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`, `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`. 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 caching | https://nextjs.org/docs/app/guides/caching-without-cache-components | Data Cache, Full Route Cache, revalidateTag/revalidatePath, 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, 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 15 caching
Next.js layers four caches on top of HTTP. They're powerful and famously confusing, so the mental model matters more than the API.
| Cache | Scope | Lives | Invalidated by |
|---|---|---|---|
| Request Memoization | Single render pass | Memory | Automatic (per request) |
| Data Cache | Across requests/users | Server | revalidate (time), revalidateTag/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 } }));Data Cache + time-based revalidation
fetch is not cached by default in 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
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 },
);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 the instant you write. This is the right pattern when you know when data changed (a mutation, a webhook).
// 1. Tag on read
const vendors = await fetch("https://api/vendors", { next: { tags: ["vendors"] } });
// 2. Invalidate on write — in a Server Action or Route Handler
"use server";
import { revalidateTag, revalidatePath } from "next/cache";
export async function addVendor(form: FormData) {
await db.vendor.create({ /* ... */ });
revalidateTag("vendors"); // every fetch tagged "vendors" is now stale
// or, to bust a specific URL's Full Route Cache:
revalidatePath("/vendors");
}
revalidateTag(tag)marks data stale — the next request regenerates it (it doesn't eagerly refetch). Pair tags with a webhook handler to invalidate exactly when upstream data changes, instead of polling on a TTL. The newer'use cache'directive (stable in Next 16 / available behind a flag earlier) moves this model into the component itself — annotate a function or component with'use cache'and pair it withcacheTag()/cacheLife()for the same tag-and-TTL control withoutfetchplumbing.
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, Redis ex | revalidateTag, 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
revalidateTag("vendors")and, where applicable,DELs the matching Redis key. 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.