← Back to dashboard
cachingtoolingfresh

Caching Integration Guide

What is caching?

The real model

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.

Text
 ██████╗ █████╗  ██████╗██╗  ██╗██╗███╗   ██╗ ██████╗
██╔════╝██╔══██╗██╔════╝██║  ██║██║████╗  ██║██╔════╝
██║     ███████║██║     ███████║██║██╔██╗ ██║██║  ███╗
██║     ██╔══██║██║     ██╔══██║██║██║╚██╗██║██║   ██║
╚██████╗██║  ██║╚██████╗██║  ██║██║██║ ╚████║╚██████╔╝
 ╚═════╝╚═╝  ╚═╝ ╚═════╝╚═╝  ╚═╝╚═╝╚═╝  ╚═══╝ ╚═════╝

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

SourceURLWhat it covers
MDN HTTP Cachinghttps://developer.mozilla.org/en-US/docs/Web/HTTP/CachingCache-Control directives, ETag/If-None-Match, 304 flow
Next.js cachinghttps://nextjs.org/docs/app/guides/caching-without-cache-componentsData Cache, Full Route Cache, revalidateTag/revalidatePath, unstable_cache
Vercel CDN cachehttps://vercel.com/docs/caching/cdn-caches-maxage, CDN-Cache-Control, Vercel-CDN-Cache-Control, x-vercel-cache
Cloudflare cache controlhttps://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/getstartedREST 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

DirectiveMeaningUse for
max-age=NFresh for N seconds in any cache (incl. browser)Per-user data the browser may keep
s-maxage=NFresh for N seconds in shared caches (CDN); overrides max-age thereCDN/edge TTL distinct from browser
stale-while-revalidate=NServe stale up to N s while refreshing in backgroundAnything where instant > perfectly fresh
no-cacheStore, but revalidate every time before reuseHTML that changes but supports ETag
no-storeNever store anywhereAuth tokens, M-Pesa callbacks, PII
privateBrowser only, never a shared cachePersonalised responses
publicCacheable even with AuthorizationShared, non-sensitive assets
immutableContent will never change — skip revalidation entirelyHashed/fingerprinted static assets

The two patterns you'll write most:

HTTP
# 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=300

stale-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.

HTTP
# 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 Modified

On 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:

TypeScript
// 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 carrying Set-Cookie or Authorization, 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:

TypeScript
// 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-maxage and stale-while-revalidate from the header sent to the browser if you don't also set CDN-Cache-Control, so the browser only sees max-age. It also does not currently support stale-if-error or proxy-revalidate for 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).

HTTP
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
Cache-Tag: catalog, prices, vendor-42
Bash
# 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.

CacheScopeLivesInvalidated by
Request MemoizationSingle render passMemoryAutomatic (per request)
Data CacheAcross requests/usersServerrevalidate (time), revalidateTag/revalidatePath (on-demand)
Full Route CacheA whole rendered routeServerData Cache revalidation, redeploy
Router CacheClient-side navBrowser memoryTime, 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:

TypeScript
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.

TypeScript
// 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).

TypeScript
// 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 with cacheTag()/cacheLife() for the same tag-and-TTL control without fetch plumbing.


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.

TypeScript
// 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:

TypeScript
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:

TypeScript
// 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, ignore

Cache 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
IdeaExpire after N seconds, hope that's often enoughInvalidate the instant the data actually changes
StalenessUp to the full TTLNear-zero
Best forData that drifts predictably (exchange rates, leaderboards)Data with a clear write/mutation event (a vendor edits a price)
CostWasted refreshes / stale windowsMust wire every writer to invalidate
codeAmani layerCDN s-maxage, fetch revalidate, Redis exrevalidateTag, 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 long s-maxage.
  • Invalidate on write, not on a timer, when you can. A revalidateTag in the mutation path beats a 30 s TTL: fresher and fewer wasted refreshes.
  • Fingerprint immutable assets. app-[hash].js with immutable, max-age=31536000 is 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 (Redis SET NX guard 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:

SymptomCheckHeader / signal
Browser shows old pageDevTools → Network → Disable cacheCache-Control, Age
Edge serving staleResponse headerx-vercel-cache (HIT/STALE) · CF-Cache-Status
Next.js route won't updateDid a mutation call revalidateTag/revalidatePath?
Redis returns old valueredis.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-revalidate so the edge node in/near East Africa answers instantly and refreshes in the background. Fingerprint and immutable-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 86400 to 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, never NEXT_PUBLIC_*, never in a CDN-cacheable response. Vercel refuses to cache Set-Cookie/Authorization responses — 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-Control env 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.local locally, Vercel env vars in prod.