Caching Integration Guide

Technology: caching · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/caching

Insight:

A cache is a faster copy of a slower truth, and the whole discipline reduces to one trade-off: how long the stored copy is allowed to lie versus how precisely you can tell it to stop. Caching is the single biggest perceived-speed lever for a low-bandwidth audience — an East-African edge hit instead of a US-origin round-trip is the difference between 400 ms and several seconds. The codeAmani default is stale-while-revalidate at the edge plus tag-based invalidation on the write path, with Upstash Redis for non-HTTP values (Daraja token reuse, STK-Push idempotency). As of Next.js 16 the framework layer has shifted to the stable use cache directive + Cache Components; the classic fetch/unstable_cache model still works when Cache Components is off.

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

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:

flowchart LR
    A["Browser<br/>Cache-Control · Cache API"] --> B["CDN / Edge<br/>Vercel · Cloudflare<br/>s-maxage · SWR"]
    B --> C["Next.js<br/>Full Route + Data Cache<br/>revalidateTag"]
    C --> D["App cache<br/>Upstash Redis<br/>SET ... EX"]
    D --> E["Origin<br/>Postgres · Daraja · LLM"]
    style A fill:#1e3a5f,stroke:#3B82F6,color:#fff
    style B fill:#1e3a5f,stroke:#3B82F6,color:#fff
    style C fill:#1e3a5f,stroke:#3B82F6,color:#fff
    style D fill:#1e3a5f,stroke:#3B82F6,color:#fff
    style E fill:#2a2a2a,stroke:#888,color:#fff

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

sequenceDiagram
    participant U as User (2G)
    participant C as Cache (edge)
    participant O as Origin
    Note over C: max-age=60, stale-while-revalidate=300
    U->>C: GET /prices  (t=0, fresh)
    C-->>U: 200 cached · instant
    U->>C: GET /prices  (t=90s, STALE but in SWR window)
    C-->>U: 200 STALE · instant (no wait!)
    C->>O: background revalidate
    O-->>C: fresh copy stored
    U->>C: GET /prices  (t=120s)
    C-->>U: 200 fresh · instant

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

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

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

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:

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_cache is 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. revalidateTag now 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-argument revalidateTag(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 new updateTag(tag) (single-arg, immediate, Server-Action-only). revalidatePath is 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, 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
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


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

Official docs: