# API Caching Optimization

> Reduce duplicate/over-fetching requests, tune cache policies by freshness, invalidate caches after mutations, and shape queries. Use when duplicate API requests appear, stale data shows after a write, TTFB exceeds 500ms, or before shipping. Not for route-level rendering strategy (use render-strategy-decision) or Core Web Vitals tuning (use rendering-performance).

- Skill: `jaykim88/api-caching-optimization` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jaykim88/api-caching-optimization`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jaykim88/api-caching-optimization/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: JayKim88 (https://skillmd.com/u/jaykim88)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jaykim88/api-caching-optimization

---


# API and Caching Optimization

## Purpose
Eliminate redundant network requests, pick the right cache policy per data type, and shape queries to fetch only what's needed. Faster responses, lower server cost, fewer cache-staleness bugs.

**Universal** — fetch-deduplication, cache-policy classification, HTTP Cache-Control headers, query shaping, and pagination apply to any client; only the data-layer library API differs.

## Procedure

1. **Audit data-layer fetch policies by freshness need**
   - Classify each query by how stale its data may be, then pick the policy per query — never set one global policy:
     - **Static / rarely-changing** → cache-first read, no revalidation
     - **Fresh-but-instant** → return cache immediately, then revalidate in background (stale-while-revalidate)
     - **Always-fresh** → skip cache read (mutation-adjacent reads where stale UI would mislead)
     - **Never-persist** → skip cache entirely, no read/write (sensitive one-off reads)
   - The default policy in most clients is cache-first — wrong for frequently-changing data
   - See Implementation for the exact policy names and tuning knobs per data library (Apollo / TanStack Query / SWR)
   - See `render-strategy-decision` for route-level `cache` / `revalidate` config that must align with these policies.

2. **Invalidate / update the cache after every mutation**
   - The #1 caching bug is a **stale read after a write** — the user changes something and still sees the old value. On each mutation, refresh the affected data: an **optimistic update** (roll back on error), a **targeted refetch/invalidate** of the affected keys, or **tag-based revalidation** on the server.
   - Invalidate **narrowly** (the affected keys/tags), not the whole cache — blanket invalidation just re-creates the over-fetching and waterfalls you're avoiding.
   - (see Implementation; TanStack `invalidateQueries` / optimistic `setQueryData`, Next `revalidateTag` / `revalidatePath`)

3. **Eliminate duplicate fetches and request waterfalls**
   - **Duplicate**: two components fetching the same resource → lift to a parent/shared hook, or rely on request-level dedup (step 8)
   - **Waterfall**: independent fetches awaited sequentially (`await a; await b`) → run in parallel (`Promise.all`); dependent/nested fetches that serialize → hoist or preload so they start early. Waterfalls, not payload size, are often the real TTFB/latency cause.

4. **Set HTTP Cache-Control headers**
   - Static assets (hashed filenames): `Cache-Control: public, max-age=31536000, immutable`
   - HTML pages: `Cache-Control: no-cache, must-revalidate`
   - User-specific data: `Cache-Control: private, no-store`
   - API responses with revalidation: `Cache-Control: public, max-age=60, stale-while-revalidate=300`

5. **Shape data-layer queries**
   - Select only the columns you need (never `*`), paginate (don't fetch unbounded rows — strategy in step 7), verify index usage on slow queries. (see Implementation; Supabase `select('id,name')` / `.range()` / `EXPLAIN ANALYZE`)

6. **Add `dns-prefetch` / `preconnect` for external origins**
   - Find third-party fetches (analytics, CDN, image hosts)
   - Add `<link rel="preconnect" href="https://...">` in `head`
   - Saves the TCP/TLS handshake cost on the first request

7. **Implement pagination / infinite scroll for large lists**
   - Never `fetch('/api/items')` and return 10,000 rows
   - Choose by use case (not a blanket preference): **cursor/keyset** for infinite scroll, large, or write-heavy lists — stable under inserts, fast at any depth, but no jump-to-page and total count is awkward; **offset** for numbered pages / jump-to-page-N / when a total count is needed — simpler, but degrades at deep offsets and drifts under concurrent writes

8. **Rely on the framework's request-level fetch dedup**
   - Where it exists, same-input fetches are deduplicated within one render pass — free; verify it's being used and don't wrap fetches in layers that defeat it. (see Implementation; Next.js Request Memoization)

9. **Verify (validation loop)**
   - Open DevTools Network with the page reloaded; if duplicate API calls visible in one render, return to step 3 (dedupe / waterfalls) and re-test
   - After a mutation, confirm the changed data updates **without a manual reload** — else return to step 2 (invalidation)
   - Measure TTFB; if static > 200ms or dynamic > 500ms, return to step 4 (Cache-Control) and step 5 (query shaping)
   - Loop until: 0 duplicate calls AND post-mutation reads are fresh AND TTFB within thresholds

## Severity tiers

| Tier | Examples | Action SLA |
|---|---|---|
| **Critical** | User-specific/sensitive data served from a shared/public cache (`Cache-Control: public` on private responses); always-fresh data (mutation-adjacent reads) served stale, misleading the user | Block release; fix immediately |
| **Major** | Duplicate fetches of the same resource per render; TTFB > 500ms (dynamic) / > 200ms (static); `select('*')` in production; unbounded list fetch (no pagination) | Fix this sprint |
| **Minor** | Missing `preconnect`/`dns-prefetch` for external origins; suboptimal `staleTime`/TTL tuning; offset pagination where cursor would be more stable | Schedule within 2 sprints |

## Completion Criteria
- [ ] Duplicate API calls per render = 0; no sequential waterfalls for independent fetches
- [ ] Post-mutation reads update without a manual reload (cache invalidated/updated on writes)
- [ ] Cache-Control set on every route
- [ ] No over-fetching: explicit column selection, no `select *`
- [ ] External-origin `dns-prefetch`/`preconnect` set
- [ ] TTFB ≤ 200ms (static), ≤ 500ms (dynamic)

## Output
- **Fetch-policy config**: per-query policy decisions documented in code (Apollo `fetchPolicy` / TanStack `staleTime`)
- **HTTP headers**: `next.config.ts` `headers()` function (or equivalent) — one route pattern per directive
- **DB queries**: explicit column selection; commit format `perf(db): select only needed columns for <query>`
- **HTML head**: `preconnect`/`dns-prefetch` links in root layout
- **Report** (paste into PR): before/after duplicate-fetch count + TTFB measurements per route

## Implementation

### React + Next.js (default)
- Data layer: Apollo (GraphQL), TanStack Query (REST), SWR; Next.js Server Component fetch with auto Request Memoization
- Cache invalidation: TanStack `queryClient.invalidateQueries` / optimistic `setQueryData` (+ rollback); Next App Router `revalidateTag` / `revalidatePath` on the **Data Cache** (persistent — distinct from per-render Request Memoization). Next App Router has 4 cache layers: Request Memoization, Data Cache, Full Route Cache, Router Cache
- HTTP headers: `next.config.ts` `headers()` function or middleware
- Apollo policies (map to the freshness classes in Procedure step 1):
  - `cache-first` (default) — static/rarely-changing; cache hit returns with no network
  - `cache-and-network` — fresh-but-instant; pair with `nextFetchPolicy: 'cache-first'` or every re-render refetches
  - `network-only` — always-fresh; skips cache read but still writes to cache
  - `no-cache` — never-persist; no read, no write
  - `cache-only` — derived views that assume a parent already fetched (throws on miss)
- TanStack Query: tune `staleTime` per query type (not globally); SWR: `revalidateOnFocus` / `dedupingInterval`
- Database: Supabase `select('id, name')` + indexes + `EXPLAIN ANALYZE`

### Other stacks
- **Vue / Nuxt**: `useFetch` and `$fetch` have built-in caching; Nuxt's `useAsyncData` provides server-component-style dedup; TanStack Query has Vue bindings
- **SvelteKit**: `+page.ts` `load()` runs server-side with built-in dedup; `+server.ts` API routes get HTTP cache headers explicitly
- **Angular**: TanStack Query Angular adapter; or RxJS `shareReplay` for in-memory dedup
- **Universal**: HTTP `Cache-Control` (immutable / no-cache / private / stale-while-revalidate) is HTTP standard — works for any client; `preconnect` / `dns-prefetch` are HTML standard

## Related skills
- `render-strategy-decision` — route-level cache/revalidate config must align with fetch policies
- `state-management-decisions` — when server state lives in the wrong store
- `rendering-performance` — TTFB and waterfalls affect LCP

## Reference
- **Key insight encoded**: Default `cache-first` is wrong for frequently-changing data — use `cache-and-network` when freshness matters but instant render is required; reserve `network-only` for mutation-adjacent reads. In Next.js Server Components, Request Memoization deduplicates same-input fetches within a single render for free — verify your fetch wrappers don't defeat it.

