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
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-decisionfor route-levelcache/revalidateconfig that must align with these policies.
- Classify each query by how stale its data may be, then pick the policy per query — never set one global policy:
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/ optimisticsetQueryData, NextrevalidateTag/revalidatePath)
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.
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
- Static assets (hashed filenames):
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; Supabaseselect('id,name')/.range()/EXPLAIN ANALYZE)
- Select only the columns you need (never
Add
dns-prefetch/preconnectfor external origins- Find third-party fetches (analytics, CDN, image hosts)
- Add
<link rel="preconnect" href="https://...">inhead - Saves the TCP/TLS handshake cost on the first request
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
- Never
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)
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/preconnectset - TTFB ≤ 200ms (static), ≤ 500ms (dynamic)
Output
- Fetch-policy config: per-query policy decisions documented in code (Apollo
fetchPolicy/ TanStackstaleTime) - HTTP headers:
next.config.tsheaders()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-prefetchlinks 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/ optimisticsetQueryData(+ rollback); Next App RouterrevalidateTag/revalidatePathon 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.tsheaders()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 networkcache-and-network— fresh-but-instant; pair withnextFetchPolicy: 'cache-first'or every re-render refetchesnetwork-only— always-fresh; skips cache read but still writes to cacheno-cache— never-persist; no read, no writecache-only— derived views that assume a parent already fetched (throws on miss)
- TanStack Query: tune
staleTimeper query type (not globally); SWR:revalidateOnFocus/dedupingInterval - Database: Supabase
select('id, name')+ indexes +EXPLAIN ANALYZE
Other stacks
- Vue / Nuxt:
useFetchand$fetchhave built-in caching; Nuxt'suseAsyncDataprovides server-component-style dedup; TanStack Query has Vue bindings - SvelteKit:
+page.tsload()runs server-side with built-in dedup;+server.tsAPI routes get HTTP cache headers explicitly - Angular: TanStack Query Angular adapter; or RxJS
shareReplayfor in-memory dedup - Universal: HTTP
Cache-Control(immutable / no-cache / private / stale-while-revalidate) is HTTP standard — works for any client;preconnect/dns-prefetchare HTML standard
Related skills
render-strategy-decision— route-level cache/revalidate config must align with fetch policiesstate-management-decisions— when server state lives in the wrong storerendering-performance— TTFB and waterfalls affect LCP
Reference
- Key insight encoded: Default
cache-firstis wrong for frequently-changing data — usecache-and-networkwhen freshness matters but instant render is required; reservenetwork-onlyfor 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.