AGENTS.md
# React Data Fetching & Caching
**Version 0.1.0**
Experimental
May 2026
> **Note:**
> This document is mainly for agents and LLMs to follow when maintaining,
> generating, or refactoring codebases. Humans may also find it useful,
> but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
## Abstract
Implementation patterns for React applications that fetch and cache many API requests without overwhelming the backend. 48 rules across 8 categories ordered by execution lifecycle impact: Request Orchestration (parallelism, batching, deduplication, route loaders), Cache Strategy (deterministic keys, normalization, staleTime, stale-while-revalidate, key factories, tiered freshness), Backend Protection (concurrency caps, request collapsing, debounce/throttle, jittered retries, circuit breakers, rate-limit awareness), Prefetch & Hydration (hover/intent prefetch, parallel loader queries, server hydration, idle prefetch, viewport-triggered, budget tiers), Failure Resilience (AbortController, bounded timeouts, scoped error boundaries, stale fallback, mutation idempotency, graceful degradation), Feed & Carousel Patterns (virtualization, cursor pagination, summary/detail split, multi-carousel failure isolation, stable keys, lazy images, bounded working set), Mutation & Invalidation (optimistic updates with rollback, surgical invalidation, setQueryData, cancel-on-mutate), and Component Patterns (stable query keys, fan-out caps, Suspense per section, colocation). Bundled with 6 scaffolding templates including both library-based (TanStack Query) and library-free (pure React + AbortController) implementations: resource query hook, no-deps resource query hook, carousel data loader (single + multi-carousel feed with failure isolation), infinite feed, prefetch link, request collapser.
---
## Table of Contents
1. [Request Orchestration](references/_sections.md#1-request-orchestration) — **CRITICAL**
- 1.1 [Avoid useEffect Fetch Chains](references/orch-avoid-effect-chains.md) — CRITICAL (prevents render-fetch-render-fetch waterfalls)
- 1.2 [Batch N+1 Fan-Out with DataLoader Pattern](references/orch-batch-n-plus-one-fanout.md) — CRITICAL (reduces N requests to 1)
- 1.3 [Deduplicate In-Flight Requests by Key](references/orch-dedupe-in-flight-requests.md) — CRITICAL (reduces M concurrent calls to 1)
- 1.4 [Lift Fetches into Route Loaders](references/orch-lift-fetch-to-route-loader.md) — CRITICAL (200-800ms saved on route entry)
- 1.5 [Move Fetches to the Server When Possible](references/orch-server-fetch-when-possible.md) — HIGH (eliminates client-side round-trip + JS payload)
- 1.6 [Parallelize Independent Fetches](references/orch-parallelize-independent-fetches.md) — CRITICAL (eliminates N-1 sequential round-trips)
- 1.7 [Prefer a Bulk Endpoint over N Parallel Endpoints](references/orch-prefer-bulk-endpoint-for-fanout.md) — CRITICAL (reduces N round-trips to 1)
2. [Cache Strategy](references/_sections.md#2-cache-strategy) — **CRITICAL**
- 2.1 [Build Deterministic Cache Keys](references/cache-deterministic-keys.md) — CRITICAL (prevents accidental cache misses on every render)
- 2.2 [Centralize Cache Keys in a Key Factory](references/cache-shared-key-factory.md) — CRITICAL (prevents read/write key drift)
- 2.3 [Normalize Shared Entities Across Views](references/cache-normalize-shared-entities.md) — CRITICAL (N-fold cache size reduction for shared entities)
- 2.4 [Set staleTime to Suppress Redundant Refetches](references/cache-set-stale-time.md) — CRITICAL (5-50x reduction in refetch rate)
- 2.5 [Tier staleTime by Data Volatility](references/cache-tiered-stale-fresh.md) — HIGH (reduces stale-data refetches 10-100×)
- 2.6 [Use select to Subscribe to a Subset of Cache Data](references/cache-select-subscribed-fields.md) — CRITICAL (5-20x reduction in re-render rate)
- 2.7 [Use Stale-While-Revalidate for Instant Renders](references/cache-stale-while-revalidate.md) — CRITICAL (0ms perceived wait for cached data)
3. [Backend Protection](references/_sections.md#3-backend-protection) — **CRITICAL**
- 3.1 [Add Jitter to Retry Backoff](references/protect-jittered-retry-backoff.md) — CRITICAL (prevents thundering-herd recovery)
- 3.2 [Cap Concurrency on Client-Side Fan-Out](references/protect-concurrency-limit-fanout.md) — CRITICAL (prevents browser connection exhaustion + backend overload)
- 3.3 [Collapse Identical Requests at the Fetch Layer](references/protect-collapse-identical-requests.md) — HIGH (prevents auth-refresh storms and retry-original races)
- 3.4 [Debounce User-Driven Fetches](references/protect-debounce-user-driven-fetches.md) — HIGH (10-30× reduction in search/filter requests)
- 3.5 [Honor Server Rate-Limit Headers Client-Side](references/protect-rate-limit-aware-client.md) — HIGH (prevents 429 retry loops + ban risk)
- 3.6 [Throttle Scroll-Triggered Fetches](references/protect-throttle-scroll-triggered.md) — HIGH (reduces 60Hz event firing to 4-10Hz fetches)
- 3.7 [Use Circuit Breakers on Persistently Failing Endpoints](references/protect-circuit-breaker.md) — HIGH (prevents retry storms on persistent failure)
4. [Prefetch & Hydration](references/_sections.md#4-prefetch-&-hydration) — **HIGH**
- 4.1 [Bound Prefetch Bandwidth by Priority Tier](references/prefetch-budget-and-priority.md) — MEDIUM-HIGH (prevents prefetch from competing with critical fetches)
- 4.2 [Hydrate the Client Cache from Server-Rendered Data](references/prefetch-hydrate-server-cache.md) — HIGH (eliminates first-fetch on cached entities)
- 4.3 [Prefetch Likely-Next Data on Idle](references/prefetch-idle-likely-next.md) — MEDIUM-HIGH (maintains instant transitions for predictable paths)
- 4.4 [Prefetch Links on Hover and Intent](references/prefetch-hover-intent-links.md) — HIGH (100-300ms faster perceived navigation)
- 4.5 [Prefetch the Next Page Before the Sentinel Hits Viewport](references/prefetch-viewport-triggered-next-page.md) — HIGH (eliminates loading-more spinners in feeds)
- 4.6 [Run Route Loader Queries in Parallel](references/prefetch-parallel-loader-queries.md) — HIGH (reduces N sequential awaits to 1 round-trip)
5. [Failure Resilience](references/_sections.md#5-failure-resilience) — **HIGH**
- 5.1 [Abort Requests on Unmount or Navigation](references/resilience-abort-on-unmount.md) — HIGH (prevents stale-response races and memory leaks)
- 5.2 [Avoid Auto-Retrying Non-Idempotent Mutations](references/resilience-no-auto-retry-mutations.md) — HIGH (prevents double-charging, duplicate posts, double sends)
- 5.3 [Bound Request Timeouts per Endpoint Class](references/resilience-bounded-timeouts.md) — HIGH (prevents indefinite hangs on degraded backends)
- 5.4 [Fall Back to Stale Cache When Fresh Fetch Fails](references/resilience-stale-fallback.md) — HIGH (prevents temporary outages from becoming visible errors)
- 5.5 [Gracefully Degrade Non-Critical Sections](references/resilience-graceful-degradation.md) — MEDIUM-HIGH (preserves core flow when peripheral data fails)
- 5.6 [Scope Error Boundaries to Data Sections](references/resilience-scoped-error-boundaries.md) — HIGH (prevents one fetch failure from breaking the entire page)
6. [Feed & Carousel Patterns](references/_sections.md#6-feed-&-carousel-patterns) — **MEDIUM-HIGH**
- 6.1 [Bound the In-Memory Working Set on Long Feeds](references/feed-bounded-working-set.md) — MEDIUM-HIGH (prevents unbounded memory growth on infinite scroll)
- 6.2 [Defer Off-Screen Feed Images with Explicit Dimensions](references/feed-image-lazy-and-sized.md) — MEDIUM-HIGH (prevents layout shift and saves 70-90% image bandwidth)
- 6.3 [Isolate Failure Across a Feed of Carousels](references/feed-multi-carousel-isolation.md) — MEDIUM-HIGH (prevents one failing carousel from breaking the homepage)
- 6.4 [Split Carousel Summaries from Item Details](references/feed-split-summary-from-detail.md) — MEDIUM-HIGH (reduces initial carousel payload 5-20×)
- 6.5 [Use Cursor Pagination over Offset](references/feed-cursor-pagination.md) — MEDIUM-HIGH (prevents skip/duplicate items as the list shifts)
- 6.6 [Use Stable Item Keys Across Paginated Pages](references/feed-stable-keys-across-pages.md) — MEDIUM (prevents full-list re-render on each new page)
- 6.7 [Virtualize Long Lists Beyond ~50 Items](references/feed-virtualize-long-lists.md) — MEDIUM-HIGH (10-100× fewer DOM nodes)
7. [Mutation & Invalidation](references/_sections.md#7-mutation-&-invalidation) — **MEDIUM**
- 7.1 [Apply Optimistic Updates with Rollback on Failure](references/mutate-optimistic-updates-with-rollback.md) — MEDIUM (eliminates 200-800ms perceived mutation latency)
- 7.2 [Cancel In-Flight Queries Before Mutating Their Cache](references/mutate-cancel-queries-on-mutate.md) — MEDIUM (prevents race conditions between mutation and refetch)
- 7.3 [Invalidate Surgically, Not Globally](references/mutate-surgical-invalidation.md) — MEDIUM (reduces post-mutation refetch storms 10-100×)
- 7.4 [Use setQueryData over Invalidate When the Result is Known](references/mutate-set-data-over-invalidate.md) — MEDIUM (eliminates 1 refetch per mutation)
8. [Component Patterns](references/_sections.md#8-component-patterns) — **MEDIUM**
- 8.1 [Cap Fan-Out of Queries Inside Lists](references/render-cap-fanout-in-lists.md) — MEDIUM (prevents unbounded query fan-out as lists grow)
- 8.2 [Colocate Fetches with Their Consumers](references/render-colocate-fetch-with-consumer.md) — MEDIUM (prevents prop drilling and unnecessary re-renders up the tree)
- 8.3 [Place Suspense Boundaries Per Logical Section](references/render-suspense-per-section.md) — MEDIUM (enables independent streaming of data sections)
- 8.4 [Stabilize Object-Shaped Query Keys](references/render-stable-query-keys.md) — MEDIUM (prevents new fetch on every parent render)
---
## References
1. [https://react.dev/reference/react/Suspense](https://react.dev/reference/react/Suspense)
2. [https://tanstack.com/query/latest/docs/framework/react/overview](https://tanstack.com/query/latest/docs/framework/react/overview)
3. [https://swr.vercel.app/](https://swr.vercel.app/)
4. [https://nextjs.org/docs/app/building-your-application/data-fetching](https://nextjs.org/docs/app/building-your-application/data-fetching)
5. [https://tanstack.com/router/latest/docs/framework/react/guide/data-loading](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading)
6. [https://tanstack.com/virtual/latest](https://tanstack.com/virtual/latest)
7. [https://vercel.com/blog/everything-about-data-fetching-in-nextjs](https://vercel.com/blog/everything-about-data-fetching-in-nextjs)
8. [https://github.com/graphql/dataloader](https://github.com/graphql/dataloader)
9. [https://developer.mozilla.org/en-US/docs/Web/API/AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
10. [https://datatracker.ietf.org/doc/html/rfc5861](https://datatracker.ietf.org/doc/html/rfc5861)
11. [https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)
12. [https://tkdodo.eu/blog/practical-react-query](https://tkdodo.eu/blog/practical-react-query)
---
## Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|------|-------------|
| [references/_sections.md](references/_sections.md) | Category definitions and impact ordering |
| [assets/templates/_template.md](assets/templates/_template.md) | Template for creating new rules |
| [SKILL.md](SKILL.md) | Quick reference entry point |
| [metadata.json](metadata.json) | Version and reference URLs |
assets/templates/_template.md
---
title: {Action-Oriented Title — start with imperative verb (Use, Avoid, Cache, Defer, Batch)}
impact: {CRITICAL | HIGH | MEDIUM-HIGH | MEDIUM | LOW-MEDIUM | LOW}
impactDescription: {quantified metric — "2-10× improvement", "200-800ms savings", "O(n) to O(1)", "prevents stale closures", "reduces N requests to 1"}
tags: {category-prefix}, {technique}, {tool-if-specific}, {related-concept}
---
## {Title}
{1-3 sentences explaining WHY this matters. What goes wrong without this pattern, and what
the cascade effect is. This section is the highest-signal part of the rule — the model
generalizes from understood reasoning, not from rules. Don't just say "use X" — explain
the failure mode in concrete terms the model can internalize.
For data-fetching rules, frame the failure in terms of: extra round-trips, extra backend
load, extra bytes downloaded, layout shift, race conditions, retry storms, or memory leaks.
The mechanism is what makes the rule generalize to novel scenarios.}
**Incorrect ({problem label}):**
```tsx
{Production-realistic bad code — not a strawman.}
{Examples should use real names (CommentList, ProductCarousel) not foo/bar.}
{Comments explain the *cost*: "// 200 requests" or "// blocks main thread".}
function BadExample() {
const { data } = useFetch('/api/things'); // 🚨 explanation of what's wrong
}
```
**Correct ({solution label}):**
```tsx
{Good code — minimal diff from incorrect when possible.}
{Comments explain the *benefit*.}
function GoodExample() {
const { data } = useQuery({
queryKey: ['things'],
queryFn: fetchThings,
staleTime: 30_000, // ← the fix
});
}
```
{Optional sections — include only when they add value:}
**Alternative ({context}):**
```tsx
{Alternative valid approach}
```
**Implementation ({name of pattern}):**
```ts
{Reusable utility worth shipping with the rule}
```
**With {framework/tool}:**
```tsx
{Tool-specific variant — e.g., Next.js App Router, TanStack Router, SWR}
```
**When NOT to use this pattern:**
- {Specific exception with rationale}
- {Another specific exception}
**Warning ({context}):**
- {Gotcha that would burn a careful reader}
**Benefits:**
- {Concrete benefit 1}
- {Concrete benefit 2}
**Pair with [[other-rule-slug]]:** {how this rule combines with another}
Reference: [{Source Title}]({source URL — use authoritative sources only})
assets/templates/carousel-data-loader.template.tsx
/**
* Recommender carousel template.
*
* Embedded patterns:
* - Summary/detail split ([[feed-split-summary-from-detail]])
* - Viewport-triggered detail fetch ([[feed-split-summary-from-detail]])
* - Hover prefetch ([[prefetch-hover-intent-links]])
* - Bulk endpoint for summaries ([[orch-prefer-bulk-endpoint-for-fanout]])
* - Per-item useQuery scoped to in-viewport cards only ([[render-cap-fanout-in-lists]])
* - Scoped error boundary + Suspense ([[resilience-scoped-error-boundaries]])
*
* Parameters to fill in:
* - ITEM_KIND: kebab-case label, e.g. "recommended" / "trending" / "recently-viewed"
* - ProductSummary / Product: replace with your domain types
* - fetchCarouselSummaries / fetchProduct: your API functions
*/
import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
// ─────────────────────────────────────────────────────────────────────────────
// Domain types — replace with yours
// ─────────────────────────────────────────────────────────────────────────────
type ProductSummary = {
id: string;
thumbnail: string;
title: string;
price: number;
};
type Product = ProductSummary & {
description: string;
variants: Array<{ id: string; name: string; price: number }>;
reviews: Array<{ id: string; rating: number; text: string }>;
// ... other heavy detail fields
};
// ─────────────────────────────────────────────────────────────────────────────
// API — replace with your client
// ─────────────────────────────────────────────────────────────────────────────
declare function fetchCarouselSummaries(
kind: string,
init?: { signal?: AbortSignal }
): Promise<ProductSummary[]>;
declare function fetchProduct(
id: string,
init?: { signal?: AbortSignal }
): Promise<Product>;
// ─────────────────────────────────────────────────────────────────────────────
// Carousel component
// ─────────────────────────────────────────────────────────────────────────────
export function RecommenderCarousel({ kind }: { kind: string }) {
const summariesQuery = useQuery({
queryKey: ['carousel', kind, 'summaries'],
queryFn: ({ signal }) => fetchCarouselSummaries(kind, { signal }),
staleTime: 5 * 60_000,
retry: 1,
throwOnError: false, // optional content — see [[resilience-graceful-degradation]]
});
if (summariesQuery.isError) return null; // optional: silent failure
if (!summariesQuery.data) return <CarouselSkeleton />;
return (
<section aria-label={`${kind} carousel`} className="overflow-x-auto">
<div className="flex gap-4">
{summariesQuery.data.map(summary => (
<CarouselCard key={summary.id} summary={summary} />
))}
</div>
</section>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Carousel card — fetches detail only when in viewport
// ─────────────────────────────────────────────────────────────────────────────
function CarouselCard({ summary }: { summary: ProductSummary }) {
const queryClient = useQueryClient();
const cardRef = useRef<HTMLDivElement>(null);
const [inViewport, setInViewport] = useState(false);
// Viewport-triggered detail fetch
useEffect(() => {
const el = cardRef.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => entry.isIntersecting && setInViewport(true),
{ rootMargin: '200px' }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
const detailQuery = useQuery({
queryKey: ['product', summary.id],
queryFn: ({ signal }) => fetchProduct(summary.id, { signal }),
enabled: inViewport,
staleTime: 5 * 60_000,
retry: 1,
});
// Hover prefetch — also covers users who never scroll the card into view
const prefetchDetail = () =>
queryClient.prefetchQuery({
queryKey: ['product', summary.id],
queryFn: ({ signal }) => fetchProduct(summary.id, { signal }),
staleTime: 5 * 60_000,
});
return (
<div
ref={cardRef}
onMouseEnter={prefetchDetail}
onPointerDown={prefetchDetail}
className="flex-shrink-0 w-48"
>
<img
src={summary.thumbnail}
alt={summary.title}
width={192}
height={240}
loading="lazy"
decoding="async"
style={{ aspectRatio: '192 / 240', objectFit: 'cover' }}
/>
<h3 className="text-sm font-medium mt-2 truncate">{summary.title}</h3>
<p className="text-xs">${summary.price.toFixed(2)}</p>
{/* Show extra detail only after the detail fetch resolves */}
{detailQuery.data && (
<p className="text-xs text-muted mt-1 line-clamp-2">
{detailQuery.data.description}
</p>
)}
</div>
);
}
function CarouselSkeleton() {
return (
<div className="flex gap-4 overflow-x-hidden">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-48">
<div className="w-full bg-gray-200 animate-pulse" style={{ aspectRatio: '192 / 240' }} />
<div className="h-4 bg-gray-200 mt-2 animate-pulse" />
<div className="h-3 bg-gray-200 mt-2 w-1/3 animate-pulse" />
</div>
))}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MULTI-CAROUSEL FEED — composing several carousels with failure isolation
// ─────────────────────────────────────────────────────────────────────────────
//
// A homepage often renders 5-10 themed carousels: trending, recommended,
// recently-viewed, "people also bought", seasonal, etc. Each one is its own
// recommender pipeline — any of them can fail independently. The pattern below
// keeps the feed working even when 2-3 carousels fail.
//
// Embedded patterns:
// - Per-carousel scoped error boundary ([[resilience-scoped-error-boundaries]])
// - Per-carousel Suspense — feed streams in, doesn't block on slowest ([[render-suspense-per-section]])
// - Bounded concurrency on summary fetches ([[protect-concurrency-limit-fanout]])
// - Tier-based fallbacks: critical shows error, decorative silently hides ([[resilience-graceful-degradation]])
// - Viewport-triggered render for below-the-fold carousels ([[prefetch-viewport-triggered-next-page]])
// - Priority ordering (critical-first reveals)
import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useSuspenseQuery, useQueryClient } from '@tanstack/react-query';
type CarouselTier = 'critical' | 'important' | 'decorative';
type CarouselConfig = {
kind: string;
title: string;
tier: CarouselTier;
};
const FEED: CarouselConfig[] = [
{ kind: 'continue-watching', title: 'Continue watching', tier: 'critical' },
{ kind: 'recommended-for-you', title: 'Recommended for you', tier: 'important' },
{ kind: 'trending', title: 'Trending now', tier: 'important' },
{ kind: 'recently-viewed', title: 'Recently viewed', tier: 'decorative' },
{ kind: 'because-you-watched', title: 'Because you watched', tier: 'decorative' },
{ kind: 'seasonal', title: 'Seasonal picks', tier: 'decorative' },
];
export function MultiCarouselFeed() {
return (
<div className="flex flex-col gap-12">
{FEED.map((config, index) => (
<CarouselSlot
key={config.kind}
config={config}
// First 2 render eagerly (above fold); the rest defer to viewport
eager={index < 2}
/>
))}
</div>
);
}
// Slot wraps a carousel with discipline-correct fallbacks:
// - critical: error visible with retry
// - important: minimal "couldn't load" placeholder
// - decorative: silent — render nothing on failure
function CarouselSlot({
config,
eager,
}: { config: CarouselConfig; eager: boolean }) {
return (
<section aria-label={config.title}>
<h2 className="text-xl font-semibold mb-4">{config.title}</h2>
<CarouselErrorBoundary tier={config.tier} title={config.title}>
<Suspense fallback={<CarouselSkeleton />}>
{eager
? <SuspendingCarousel kind={config.kind} />
: <DeferredCarousel kind={config.kind} />}
</Suspense>
</CarouselErrorBoundary>
</section>
);
}
// Tier-aware error fallback
function CarouselErrorBoundary({
tier, title, children,
}: { tier: CarouselTier; title: string; children: ReactNode }) {
return (
<ErrorBoundary
onError={(error) => {
// Log every silent failure to observability — silence on the UI,
// not silence in monitoring
reportError({ carousel: title, tier, error });
}}
fallbackRender={({ resetErrorBoundary }) => {
if (tier === 'decorative') return null; // hidden — user doesn't notice
if (tier === 'important') return <CarouselError minimal onRetry={resetErrorBoundary} />;
return <CarouselError onRetry={resetErrorBoundary} />;
}}
>
{children}
</ErrorBoundary>
);
}
// Above-the-fold variant — uses useSuspenseQuery so it suspends until ready
function SuspendingCarousel({ kind }: { kind: string }) {
const { data: summaries } = useSuspenseQuery({
queryKey: ['carousel', kind, 'summaries'],
queryFn: ({ signal }) => fetchCarouselSummaries(kind, { signal }),
staleTime: 5 * 60_000,
});
return <CarouselTrack summaries={summaries} />;
}
// Below-the-fold variant — defer mounting until the slot enters the viewport
function DeferredCarousel({ kind }: { kind: string }) {
const ref = useRef<HTMLDivElement>(null);
const [inViewport, setInViewport] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => entry.isIntersecting && setInViewport(true),
{ rootMargin: '300px' }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
if (!inViewport) {
// Reserve space so the slot has stable height while invisible
return <div ref={ref} style={{ height: 280 }} />;
}
// Now Suspense kicks in via the inner component
return <div ref={ref}><SuspendingCarousel kind={kind} /></div>;
}
function CarouselTrack({ summaries }: { summaries: ProductSummary[] }) {
return (
<div className="flex gap-4 overflow-x-auto">
{summaries.map(s => <CarouselCard key={s.id} summary={s} />)}
</div>
);
}
function CarouselError({
minimal = false, onRetry,
}: { minimal?: boolean; onRetry: () => void }) {
if (minimal) {
return (
<button
onClick={onRetry}
className="text-sm text-muted underline"
>
Couldn't load — tap to retry
</button>
);
}
return (
<div className="rounded border p-4 bg-yellow-50">
<p className="font-medium">We couldn't load this section.</p>
<button onClick={onRetry} className="mt-2 text-sm underline">
Try again
</button>
</div>
);
}
declare function reportError(payload: {
carousel: string;
tier: CarouselTier;
error: unknown;
}): void;
// ─────────────────────────────────────────────────────────────────────────────
// CONCURRENCY GUARDRAIL FOR MULTI-CAROUSEL FEEDS
// ─────────────────────────────────────────────────────────────────────────────
//
// With 6 carousels each fetching ~30 items, the homepage can fire 6 summary
// requests + dozens of detail prefetches in parallel. Wire a global
// concurrency cap so the feed degrades to "smooth and a bit slower" rather
// than "all fail with timeouts."
//
// Configure once at QueryClient setup:
//
// const queryClient = new QueryClient({
// defaultOptions: {
// queries: {
// // Default tier — overridden per-query for realtime / static data
// staleTime: 60_000,
// retry: (attempt, err) =>
// attempt < 2 && !(err instanceof HttpError && err.status >= 400 && err.status < 500),
// retryDelay: attempt => Math.random() * Math.min(30_000, 1000 * 2 ** attempt),
// },
// },
// });
//
// For the underlying `fetch` layer, wrap calls in the request collapser
// from `request-collapser.template.ts` — it caps concurrency at 6.
declare class HttpError extends Error {
status: number;
}
assets/templates/infinite-feed.template.tsx
/**
* Cursor-paginated infinite feed with virtualization.
*
* Embedded patterns:
* - Cursor pagination ([[feed-cursor-pagination]])
* - Bounded working set via maxPages ([[feed-bounded-working-set]])
* - Virtualization ([[feed-virtualize-long-lists]])
* - Viewport-triggered next-page prefetch ([[prefetch-viewport-triggered-next-page]])
* - Stable keys ([[feed-stable-keys-across-pages]])
* - Stale-while-revalidate ([[cache-stale-while-revalidate]])
* - AbortSignal forwarded to fetch ([[resilience-abort-on-unmount]])
*
* Parameters to fill in:
* - FEED_NAME: kebab-case feed identifier (e.g. "home-feed", "user-posts")
* - FeedItem: row type
* - fetchFeedPage: your paginated API
* - estimatedRowHeight: approximate row height for the virtualizer
*/
import { useEffect, useMemo, useRef } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
// ─────────────────────────────────────────────────────────────────────────────
// Domain types — replace with yours
// ─────────────────────────────────────────────────────────────────────────────
type FeedItem = {
id: string;
// ... your fields
};
type FeedPage = {
items: FeedItem[];
nextCursor: string | null;
};
declare function fetchFeedPage(
cursor: string | null,
init?: { signal?: AbortSignal }
): Promise<FeedPage>;
// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────
const FEED_NAME = 'FEED_NAME';
const ESTIMATED_ROW_HEIGHT = 120;
const OVERSCAN = 5;
const PREFETCH_ROOT_MARGIN = '800px';
export function InfiniteFeed() {
const parentRef = useRef<HTMLDivElement>(null);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading,
} = useInfiniteQuery({
queryKey: [FEED_NAME],
queryFn: ({ pageParam, signal }) => fetchFeedPage(pageParam, { signal }),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.nextCursor,
maxPages: 8, // bounded working set
staleTime: 30_000,
refetchOnWindowFocus: false,
});
// Flatten + dedupe items across pages
const items = useMemo(() => {
const all = data?.pages.flatMap(p => p.items) ?? [];
const seen = new Set<string>();
return all.filter(item => seen.has(item.id) ? false : (seen.add(item.id), true));
}, [data]);
// Virtualizer — only ~25 rows in DOM regardless of items.length
const virtualizer = useVirtualizer({
count: hasNextPage ? items.length + 1 : items.length, // +1 slot for the next-page sentinel
getScrollElement: () => parentRef.current,
estimateSize: () => ESTIMATED_ROW_HEIGHT,
overscan: OVERSCAN,
});
// Viewport-triggered next-page prefetch
// Fires when the last virtual row is near the rendered range
useEffect(() => {
if (!hasNextPage || isFetchingNextPage) return;
const lastVirtual = virtualizer.getVirtualItems().at(-1);
if (lastVirtual && lastVirtual.index >= items.length - OVERSCAN) {
fetchNextPage();
}
}, [
virtualizer.getVirtualItems(),
items.length,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
]);
if (isLoading) return <FeedSkeleton />;
if (isError && items.length === 0) return <FeedError />;
return (
<div ref={parentRef} className="h-screen overflow-y-auto" data-feed={FEED_NAME}>
<div
style={{ height: virtualizer.getTotalSize(), width: '100%', position: 'relative' }}
>
{virtualizer.getVirtualItems().map(virtualRow => {
const item = items[virtualRow.index];
const isLoaderRow = virtualRow.index > items.length - 1;
return (
<div
key={isLoaderRow ? 'loader' : item.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
hasNextPage ? <Loader /> : <EndOfFeed />
) : (
<FeedRow item={item} />
)}
</div>
);
})}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Row — fill in your render
// ─────────────────────────────────────────────────────────────────────────────
function FeedRow({ item }: { item: FeedItem }) {
return (
<article className="border-b p-4">
{/* Replace with your row content */}
<div>{item.id}</div>
</article>
);
}
function FeedSkeleton() {
return (
<div className="space-y-4 p-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-24 bg-gray-200 animate-pulse rounded" />
))}
</div>
);
}
function Loader() { return <div className="p-4 text-center text-muted">Loading more…</div>; }
function EndOfFeed() { return <div className="p-4 text-center text-muted">End of feed</div>; }
function FeedError() { return <div className="p-4 text-center text-red-600">Couldn't load feed</div>; }
assets/templates/prefetch-link.template.tsx
/**
* Hover/intent prefetch link wrapper.
*
* Embedded patterns:
* - Hover + pointerdown prefetch ([[prefetch-hover-intent-links]])
* - Connection-aware gating ([[prefetch-budget-and-priority]])
* - Single trigger per hover session (avoid re-firing on mouse jitter)
*
* Parameters to fill in:
* - The `prefetch` argument: a function that primes the cache for the destination.
* Typically calls `queryClient.prefetchQuery` with the same key/queryFn the
* destination's component would use.
*
* Wrap your existing <Link> (Next, TanStack Router) — this works with any of them.
*/
import { useCallback, useRef, type AnchorHTMLAttributes, type ReactNode } from 'react';
type PrefetchLinkProps = {
href: string;
prefetch: () => Promise<unknown> | unknown;
children: ReactNode;
/** Tag to wrap with — defaults to plain anchor. Pass NextLink / TanStack Link if desired. */
as?: React.ElementType;
} & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>;
export function PrefetchLink({
href,
prefetch,
children,
as: Component = 'a',
...anchorProps
}: PrefetchLinkProps) {
const prefetchedRef = useRef(false);
const tryPrefetch = useCallback(() => {
if (prefetchedRef.current) return;
// Skip on slow / save-data connections — respect user bandwidth
// @ts-expect-error — Network Information API not in lib.dom
const conn = typeof navigator !== 'undefined' ? navigator.connection : undefined;
if (conn?.saveData) return;
if (conn?.effectiveType === '2g' || conn?.effectiveType === 'slow-2g') return;
prefetchedRef.current = true;
try {
const maybePromise = prefetch();
if (maybePromise && typeof (maybePromise as Promise<unknown>).then === 'function') {
// Swallow prefetch errors — never block navigation on a failed prefetch
(maybePromise as Promise<unknown>).catch(() => {
prefetchedRef.current = false; // allow retry on next hover
});
}
} catch {
prefetchedRef.current = false;
}
}, [prefetch]);
return (
<Component
href={href}
onMouseEnter={tryPrefetch}
onFocus={tryPrefetch}
onPointerDown={tryPrefetch}
onTouchStart={tryPrefetch}
{...anchorProps}
>
{children}
</Component>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Usage example
// ─────────────────────────────────────────────────────────────────────────────
//
// import { useQueryClient } from '@tanstack/react-query';
//
// function ProductLink({ id, name }: { id: string; name: string }) {
// const queryClient = useQueryClient();
// return (
// <PrefetchLink
// href={`/product/${id}`}
// prefetch={() => queryClient.prefetchQuery({
// queryKey: ['product', id],
// queryFn: () => fetchProduct(id),
// staleTime: 60_000,
// })}
// >
// {name}
// </PrefetchLink>
// );
// }
assets/templates/request-collapser.template.ts
/**
* In-flight request collapser + concurrency limiter.
*
* Embedded patterns:
* - Request collapsing by signature ([[protect-collapse-identical-requests]])
* - Concurrency limit for fan-out ([[protect-concurrency-limit-fanout]])
* - GET-only safe-method dedup; mutations never collapsed
*
* Use cases:
* - Imperative code paths that bypass useQuery (auth refresh, logging, polling)
* - APIs without a query library that still need dedup + concurrency limits
* - Pre-warming a cache where multiple modules might race to the same call
*
* Parameters to tune:
* - DEFAULT_CONCURRENCY: max simultaneous in-flight (6 is a safe default for HTTP/1.1)
* - signatureFn: how to derive a dedup signature from request input
*/
// ─────────────────────────────────────────────────────────────────────────────
// Concurrency limiter — bounded queue, no dependency
// ─────────────────────────────────────────────────────────────────────────────
export function createLimiter(max: number) {
let active = 0;
const queue: Array<() => void> = [];
const next = () => {
if (active >= max || queue.length === 0) return;
active++;
queue.shift()!();
};
return <T,>(fn: () => Promise<T>): Promise<T> =>
new Promise<T>((resolve, reject) => {
queue.push(() =>
fn().then(resolve, reject).finally(() => { active--; next(); })
);
next();
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Request collapser — dedup identical concurrent calls by signature
// ─────────────────────────────────────────────────────────────────────────────
type Signature = string;
const DEFAULT_CONCURRENCY = 6;
export class RequestCollapser {
private inflight = new Map<Signature, Promise<unknown>>();
private limit = createLimiter(DEFAULT_CONCURRENCY);
constructor(opts?: { concurrency?: number }) {
if (opts?.concurrency) this.limit = createLimiter(opts.concurrency);
}
/**
* Collapse identical concurrent calls into a shared promise.
*
* @param signature Unique key for the request. Same signature → shared result.
* @param fn The actual fetch — only invoked when no in-flight match exists.
*/
collapse<T>(signature: Signature, fn: () => Promise<T>): Promise<T> {
const existing = this.inflight.get(signature) as Promise<T> | undefined;
if (existing) return existing;
const p = this.limit(fn).finally(() => this.inflight.delete(signature));
this.inflight.set(signature, p);
return p;
}
/** For mutations / non-idempotent calls — always invokes fn, no dedup. */
enqueue<T>(fn: () => Promise<T>): Promise<T> {
return this.limit(fn);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Wrapped fetch — drop-in replacement for `fetch` with dedup + concurrency
// ─────────────────────────────────────────────────────────────────────────────
const collapser = new RequestCollapser({ concurrency: 6 });
/**
* Fetch with automatic in-flight deduplication for GET requests and concurrency limit.
* Non-GET methods bypass dedup (mutations are not idempotent).
*
* The returned Response is cloned for each caller so they can read the body independently.
*/
export function collapsedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
if (method !== 'GET' && method !== 'HEAD') {
// Non-idempotent — never collapse, but still respect concurrency
return collapser.enqueue(() => fetch(input, init));
}
const signature = `${method} ${url}`;
return collapser
.collapse(signature, () => fetch(input, init))
.then(res => res.clone()); // each caller gets their own readable body
}
// ─────────────────────────────────────────────────────────────────────────────
// Usage example
// ─────────────────────────────────────────────────────────────────────────────
//
// // Two modules independently call this in the same tick → one HTTP request
// async function getConfig() {
// const res = await collapsedFetch('/api/config');
// return res.json();
// }
//
// // Fan-out limited to 6 concurrent — backend sees smooth pressure
// const products = await Promise.all(
// ids.map(id => collapsedFetch(`/api/products/${id}`).then(r => r.json()))
// );
assets/templates/use-resource-query-no-deps.template.tsx
/**
* Library-free data-fetching template.
*
* Implements the same patterns as `use-resource-query.template.tsx` but with
* ZERO library dependencies — only `react` and the standard web platform
* (fetch, AbortController, AbortSignal). Use this when:
* - You're in a tightly bundle-controlled app and can't add TanStack/SWR (~12-40kb)
* - You're inside a third-party widget that mustn't conflict with the host's libraries
* - You're vendoring patterns into a codebase that has banned new dependencies
* - You want to understand exactly what TanStack/SWR are doing under the hood
*
* What's implemented (all referenced rules):
* - Module-level cache with subscription model ([[cache-stale-while-revalidate]])
* - Deterministic JSON key canonicalization ([[cache-deterministic-keys]])
* - In-flight request deduplication by key ([[orch-dedupe-in-flight-requests]])
* - staleTime + gcTime semantics ([[cache-set-stale-time]] / [[cache-tiered-stale-fresh]])
* - AbortSignal forwarding + auto-cancel on unmount ([[resilience-abort-on-unmount]])
* - Bounded timeout per fetch ([[resilience-bounded-timeouts]])
* - Exponential backoff with full jitter on retry ([[protect-jittered-retry-backoff]])
* - 4xx errors do not retry ([[protect-jittered-retry-backoff]])
* - Concurrency-limited fetch wrapper ([[protect-concurrency-limit-fanout]])
*
* What's NOT implemented (out of scope for a single file):
* - Normalized entity store
* - Optimistic mutations with rollback
* - Persistence (localStorage hydration)
* - SSR/hydration boundaries
* - Window-focus refetching
* For any of these, prefer adopting TanStack Query — re-implementing them
* correctly is 10x the code below.
*/
import { useEffect, useReducer, useRef, useSyncExternalStore } from 'react';
// ─────────────────────────────────────────────────────────────────────────────
// 1. CACHE — module-level singleton with pub/sub
// ─────────────────────────────────────────────────────────────────────────────
type CacheEntry<T> = {
status: 'pending' | 'success' | 'error';
data?: T;
error?: unknown;
updatedAt: number; // last successful resolve time
subscribers: Set<() => void>;
inflight?: Promise<T>; // shared in-flight promise for dedup
gcTimer?: ReturnType<typeof setTimeout>;
};
const cache = new Map<string, CacheEntry<unknown>>();
function getEntry<T>(key: string): CacheEntry<T> {
let entry = cache.get(key) as CacheEntry<T> | undefined;
if (!entry) {
entry = { status: 'pending', updatedAt: 0, subscribers: new Set() };
cache.set(key, entry as CacheEntry<unknown>);
}
return entry;
}
function notify(entry: CacheEntry<unknown>) {
entry.subscribers.forEach(cb => cb());
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. KEY CANONICALIZATION — equal-by-value produces equal-by-key
// ─────────────────────────────────────────────────────────────────────────────
export function canonicalKey(parts: unknown[]): string {
return JSON.stringify(parts, function (this: unknown, _key, value) {
if (value === undefined) return undefined;
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return [...value].sort();
return Object.keys(value as Record<string, unknown>)
.sort()
.reduce<Record<string, unknown>>((acc, k) => {
const v = (value as Record<string, unknown>)[k];
if (v !== undefined) acc[k] = v;
return acc;
}, {});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. CONCURRENCY LIMITER — bounds parallel fetches
// ─────────────────────────────────────────────────────────────────────────────
function createLimiter(max: number) {
let active = 0;
const queue: Array<() => void> = [];
const next = () => {
if (active >= max || queue.length === 0) return;
active++;
queue.shift()!();
};
return <T,>(fn: () => Promise<T>): Promise<T> =>
new Promise<T>((resolve, reject) => {
queue.push(() =>
fn().then(resolve, reject).finally(() => { active--; next(); })
);
next();
});
}
const fetchLimit = createLimiter(6);
// ─────────────────────────────────────────────────────────────────────────────
// 4. RETRY + TIMEOUT WRAPPER
// ─────────────────────────────────────────────────────────────────────────────
export class HttpError extends Error {
constructor(public status: number, public body: string) {
super(`HTTP ${status}`);
this.name = 'HttpError';
}
}
type RunOpts = {
signal?: AbortSignal;
timeoutMs?: number;
maxAttempts?: number;
};
async function runWithRetry<T>(
fn: (signal: AbortSignal) => Promise<T>,
{ signal, timeoutMs = 8000, maxAttempts = 3 }: RunOpts = {}
): Promise<T> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Merge external cancel + per-attempt timeout
const timeoutSig = AbortSignal.timeout(timeoutMs);
const merged = signal
? AbortSignal.any([signal, timeoutSig])
: timeoutSig;
try {
return await fn(merged);
} catch (err) {
// External cancel — propagate, no retry
if (signal?.aborted) throw err;
// 4xx — don't retry malformed/forbidden requests
if (err instanceof HttpError && err.status >= 400 && err.status < 500) throw err;
// Last attempt — give up
if (attempt === maxAttempts - 1) throw err;
// Full-jitter exponential backoff
const cap = Math.min(30_000, 1000 * 2 ** attempt);
const delay = Math.random() * cap;
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error('unreachable');
}
// ─────────────────────────────────────────────────────────────────────────────
// 5. CORE FETCH — wraps a fetcher with cache + dedup + retry
// ─────────────────────────────────────────────────────────────────────────────
type FetchOpts<T> = {
key: string;
fetcher: (signal: AbortSignal) => Promise<T>;
staleTime?: number;
gcTime?: number;
signal?: AbortSignal;
};
async function fetchAndStore<T>(opts: FetchOpts<T>): Promise<T> {
const entry = getEntry<T>(opts.key);
// In-flight dedup — concurrent callers share the same promise
if (entry.inflight) return entry.inflight;
entry.status = 'pending';
entry.inflight = fetchLimit(() =>
runWithRetry(opts.fetcher, { signal: opts.signal })
)
.then(data => {
entry.status = 'success';
entry.data = data;
entry.error = undefined;
entry.updatedAt = Date.now();
return data;
})
.catch(err => {
entry.status = 'error';
entry.error = err;
throw err;
})
.finally(() => {
entry.inflight = undefined;
notify(entry as CacheEntry<unknown>);
});
return entry.inflight;
}
function isFresh(entry: CacheEntry<unknown>, staleTime: number) {
return entry.status === 'success' && Date.now() - entry.updatedAt < staleTime;
}
// ─────────────────────────────────────────────────────────────────────────────
// 6. PUBLIC HOOK — useResourceQuery
// ─────────────────────────────────────────────────────────────────────────────
export type QueryState<T> = {
data: T | undefined;
error: unknown;
status: 'pending' | 'success' | 'error';
isFetching: boolean;
refetch: () => void;
};
export function useResourceQuery<T>(args: {
keyParts: unknown[];
fetcher: (signal: AbortSignal) => Promise<T>;
staleTime?: number; // default 30s
gcTime?: number; // default 5m
enabled?: boolean; // default true
}): QueryState<T> {
const {
keyParts,
fetcher,
staleTime = 30_000,
gcTime = 5 * 60_000,
enabled = true,
} = args;
const key = canonicalKey(keyParts);
const entry = getEntry<T>(key);
const [, forceRender] = useReducer((n: number) => n + 1, 0);
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
// Subscribe to cache updates for this key — via useSyncExternalStore for
// concurrent-rendering safety
useSyncExternalStore(
(cb) => {
entry.subscribers.add(cb);
return () => {
entry.subscribers.delete(cb);
// Schedule GC if no subscribers remain
if (entry.subscribers.size === 0) {
entry.gcTimer = setTimeout(() => {
if (entry.subscribers.size === 0) cache.delete(key);
}, gcTime);
}
};
},
() => entry.updatedAt,
() => 0 // SSR snapshot
);
// Trigger a fetch if needed
useEffect(() => {
if (!enabled) return;
if (entry.gcTimer) { clearTimeout(entry.gcTimer); entry.gcTimer = undefined; }
if (isFresh(entry, staleTime)) return;
if (entry.inflight) return;
const ctrl = new AbortController();
fetchAndStore({
key,
fetcher: (sig) => fetcherRef.current(sig),
staleTime,
gcTime,
signal: ctrl.signal,
}).then(forceRender, () => forceRender());
// Cancel this component's fetch on unmount — but only if it's still
// the active inflight (other subscribers may have completed it already)
return () => {
if (entry.subscribers.size === 0) ctrl.abort();
};
}, [key, enabled, staleTime, gcTime]);
const refetch = () => {
if (entry.inflight) return;
const ctrl = new AbortController();
fetchAndStore({
key,
fetcher: (sig) => fetcherRef.current(sig),
staleTime,
gcTime,
signal: ctrl.signal,
}).catch(() => {});
};
return {
data: entry.data,
error: entry.error,
status: entry.status,
isFetching: !!entry.inflight,
refetch,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// 7. IMPERATIVE HELPERS — prefetch and setQueryData equivalents
// ─────────────────────────────────────────────────────────────────────────────
export function prefetchResource<T>(
keyParts: unknown[],
fetcher: (signal: AbortSignal) => Promise<T>,
staleTime = 30_000
): Promise<T> {
const key = canonicalKey(keyParts);
const entry = getEntry<T>(key);
if (isFresh(entry, staleTime) && entry.data !== undefined) {
return Promise.resolve(entry.data);
}
return fetchAndStore({ key, fetcher, staleTime });
}
export function setCacheValue<T>(keyParts: unknown[], data: T): void {
const key = canonicalKey(keyParts);
const entry = getEntry<T>(key);
entry.status = 'success';
entry.data = data;
entry.error = undefined;
entry.updatedAt = Date.now();
notify(entry as CacheEntry<unknown>);
}
export function invalidateCache(predicate: (key: string) => boolean): void {
for (const [key, entry] of cache) {
if (!predicate(key)) continue;
// Mark stale by zeroing updatedAt — next mount refetches
entry.updatedAt = 0;
notify(entry as CacheEntry<unknown>);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// USAGE EXAMPLES
// ─────────────────────────────────────────────────────────────────────────────
//
// // Define a resource fetcher
// type Product = { id: string; name: string; price: number };
//
// const fetchProduct = (id: string) => (signal: AbortSignal) =>
// fetch(`/api/products/${id}`, { signal }).then(async r => {
// if (!r.ok) throw new HttpError(r.status, await r.text());
// return r.json() as Promise<Product>;
// });
//
// // Use in a component — fully library-free
// function ProductCard({ id }: { id: string }) {
// const { data, error, status, refetch } = useResourceQuery({
// keyParts: ['product', id],
// fetcher: fetchProduct(id),
// staleTime: 5 * 60_000,
// });
//
// if (status === 'pending') return <Skeleton />;
// if (status === 'error') return <button onClick={refetch}>Retry</button>;
// return <Card product={data!} />;
// }
//
// // Hover-prefetch — re-use prefetchResource imperatively
// <a
// href={`/product/${id}`}
// onMouseEnter={() => prefetchResource(['product', id], fetchProduct(id))}
// />
declare function Skeleton(): JSX.Element;
declare function Card(props: { product: unknown }): JSX.Element;
assets/templates/use-resource-query.template.tsx
/**
* Standardized query hook template.
*
* Embeds the patterns this skill teaches:
* - Key factory ([[cache-shared-key-factory]])
* - Deterministic key serialization ([[cache-deterministic-keys]])
* - AbortSignal forwarding ([[resilience-abort-on-unmount]])
* - Per-endpoint timeout ([[resilience-bounded-timeouts]])
* - Jittered retry ([[protect-jittered-retry-backoff]])
* - Tiered staleTime ([[cache-tiered-stale-fresh]])
* - Optional Suspense via useSuspenseQuery ([[render-suspense-per-section]])
*
* Parameters to fill in:
* - RESOURCE_NAME: singular kebab-case name (e.g. "product", "user", "comment")
* - Resource: TypeScript type for the resource
* - GetParams: parameters for fetching a single resource
* - ListParams: parameters for fetching a list (filters, sort, etc.)
* - apiBase: the endpoint path (e.g. "/api/products")
* - staleTier: one of STALE.realtime / fresh / warm / cold / static
*/
import {
useQuery,
useSuspenseQuery,
useInfiniteQuery,
type QueryClient,
type UseQueryOptions,
} from '@tanstack/react-query';
// ─────────────────────────────────────────────────────────────────────────────
// Configuration tiers
// ─────────────────────────────────────────────────────────────────────────────
export const STALE = {
realtime: 5_000,
fresh: 30_000,
warm: 5 * 60_000,
cold: 60 * 60_000,
static: 24 * 60 * 60_000,
} as const;
const TIMEOUT_MS = 8000;
// ─────────────────────────────────────────────────────────────────────────────
// Resource definition — duplicate this section per resource and rename
// ─────────────────────────────────────────────────────────────────────────────
const RESOURCE_NAME = 'RESOURCE_NAME'; // e.g. 'product'
type Resource = {
id: string;
// ... add fields
};
type GetParams = { id: string };
type ListParams = { /* filters */ };
const apiBase = '/api/RESOURCE_NAME';
// ─────────────────────────────────────────────────────────────────────────────
// Key factory — single source of truth for cache keys
// See [[cache-shared-key-factory]]
// ─────────────────────────────────────────────────────────────────────────────
export const resourceKeys = {
all: [RESOURCE_NAME] as const,
lists: () => [...resourceKeys.all, 'list'] as const,
list: (params: ListParams) => [...resourceKeys.lists(), canonical(params)] as const,
details: () => [...resourceKeys.all, 'detail'] as const,
detail: (id: string) => [...resourceKeys.details(), id] as const,
};
// Canonicalize filter objects so equal-by-value produces equal-by-key
// See [[cache-deterministic-keys]]
function canonical<T extends Record<string, unknown>>(obj: T): T {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => [k, Array.isArray(v) ? [...v].sort() : v])
) as T;
}
// ─────────────────────────────────────────────────────────────────────────────
// Fetch functions — forward AbortSignal, bound timeout, throw on non-2xx
// See [[resilience-abort-on-unmount]] + [[resilience-bounded-timeouts]]
// ─────────────────────────────────────────────────────────────────────────────
async function fetchResource(
{ id }: GetParams,
{ signal }: { signal?: AbortSignal } = {}
): Promise<Resource> {
const merged = signal
? AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT_MS)])
: AbortSignal.timeout(TIMEOUT_MS);
const res = await fetch(`${apiBase}/${encodeURIComponent(id)}`, { signal: merged });
if (!res.ok) throw new HttpError(res.status, await res.text());
return res.json();
}
async function fetchResourceList(
params: ListParams,
{ signal }: { signal?: AbortSignal } = {}
): Promise<{ items: Resource[]; nextCursor: string | null }> {
const merged = signal
? AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT_MS)])
: AbortSignal.timeout(TIMEOUT_MS);
const search = new URLSearchParams(params as Record<string, string>).toString();
const res = await fetch(`${apiBase}?${search}`, { signal: merged });
if (!res.ok) throw new HttpError(res.status, await res.text());
return res.json();
}
export class HttpError extends Error {
constructor(public status: number, public body: string) {
super(`HTTP ${status}: ${body.slice(0, 200)}`);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Hooks
// ─────────────────────────────────────────────────────────────────────────────
export function useResource(
params: GetParams,
options?: Omit<UseQueryOptions<Resource>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: resourceKeys.detail(params.id),
queryFn: ({ signal }) => fetchResource(params, { signal }),
staleTime: STALE.warm,
retry: (attempt, err) => attempt < 2 && err instanceof HttpError && err.status >= 500,
retryDelay: attempt => Math.random() * Math.min(30_000, 1000 * 2 ** attempt),
...options,
});
}
/** Same hook but suspends — use inside <Suspense> + <ErrorBoundary>. */
export function useResourceSuspense(params: GetParams) {
return useSuspenseQuery({
queryKey: resourceKeys.detail(params.id),
queryFn: ({ signal }) => fetchResource(params, { signal }),
staleTime: STALE.warm,
});
}
export function useResourceList(params: ListParams) {
return useQuery({
queryKey: resourceKeys.list(params),
queryFn: ({ signal }) => fetchResourceList(params, { signal }),
staleTime: STALE.fresh,
placeholderData: (prev) => prev,
});
}
/** Infinite/cursor-paginated list. See [[feed-cursor-pagination]]. */
export function useResourceInfinite(params: ListParams) {
return useInfiniteQuery({
queryKey: [...resourceKeys.lists(), 'infinite', canonical(params)] as const,
queryFn: ({ pageParam, signal }) =>
fetchResourceList({ ...params, cursor: pageParam } as ListParams, { signal }),
initialPageParam: null as string | null,
getNextPageParam: (last: { nextCursor: string | null }) => last.nextCursor,
maxPages: 10, // bounded working set — see [[feed-bounded-working-set]]
staleTime: STALE.fresh,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Prefetch helpers (for route loaders + intent prefetching)
// ─────────────────────────────────────────────────────────────────────────────
export function prefetchResource(qc: QueryClient, id: string) {
return qc.prefetchQuery({
queryKey: resourceKeys.detail(id),
queryFn: ({ signal }) => fetchResource({ id }, { signal }),
staleTime: STALE.warm,
});
}
export function ensureResource(qc: QueryClient, id: string) {
return qc.ensureQueryData({
queryKey: resourceKeys.detail(id),
queryFn: ({ signal }) => fetchResource({ id }, { signal }),
staleTime: STALE.warm,
});
}
metadata.json
{
"version": "0.1.1",
"organization": "Experimental",
"technology": "React Data Fetching & Caching",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Implementation patterns for React applications that fetch and cache many API requests without overwhelming the backend. 48 rules across 8 categories ordered by execution lifecycle impact: Request Orchestration (parallelism, batching, deduplication, route loaders), Cache Strategy (deterministic keys, normalization, staleTime, stale-while-revalidate, key factories, tiered freshness), Backend Protection (concurrency caps, request collapsing, debounce/throttle, jittered retries, circuit breakers, rate-limit awareness), Prefetch & Hydration (hover/intent prefetch, parallel loader queries, server hydration, idle prefetch, viewport-triggered, budget tiers), Failure Resilience (AbortController, bounded timeouts, scoped error boundaries, stale fallback, mutation idempotency, graceful degradation), Feed & Carousel Patterns (virtualization, cursor pagination, summary/detail split, multi-carousel failure isolation, stable keys, lazy images, bounded working set), Mutation & Invalidation (optimistic updates with rollback, surgical invalidation, setQueryData, cancel-on-mutate), and Component Patterns (stable query keys, fan-out caps, Suspense per section, colocation). Bundled with 6 scaffolding templates including both library-based (TanStack Query) and library-free (pure React + AbortController) implementations: resource query hook, no-deps resource query hook, carousel data loader (single + multi-carousel feed with failure isolation), infinite feed, prefetch link, request collapser.",
"references": [
"https://react.dev/reference/react/Suspense",
"https://tanstack.com/query/latest/docs/framework/react/overview",
"https://swr.vercel.app/",
"https://nextjs.org/docs/app/building-your-application/data-fetching",
"https://tanstack.com/router/latest/docs/framework/react/guide/data-loading",
"https://tanstack.com/virtual/latest",
"https://vercel.com/blog/everything-about-data-fetching-in-nextjs",
"https://github.com/graphql/dataloader",
"https://developer.mozilla.org/en-US/docs/Web/API/AbortController",
"https://datatracker.ietf.org/doc/html/rfc5861",
"https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/",
"https://tkdodo.eu/blog/practical-react-query"
]
}
references/_sections.md
# Sections
This file defines all sections, their ordering, impact levels, and descriptions.
The section ID (in parentheses) is the filename prefix used to group rules.
Categories are ordered by lifecycle position and cascade effect. Problems at the
top (orchestration, cache keys) multiply downstream — getting them wrong creates
hundreds of redundant backend hits per user. Problems at the bottom (render
patterns, mutation invalidation) are localized and addable later.
## Impact tier definitions
Used by rule frontmatter and category headings:
| Tier | Meaning | When to assign |
|------|---------|----------------|
| **CRITICAL** | Cascades through all downstream operations; wrong here = self-inflicted outage or N× backend amplification | Multiplicative bugs (N+1 fan-out, missing concurrency cap, wrong cache key strategy) |
| **HIGH** | Affects a major user-visible path or backend budget; not multiplicative but compounding | Per-endpoint policy (debounce, timeout, retry-with-jitter), architectural choice (server-fetch, hydration) |
| **MEDIUM-HIGH** | Important for a specific scenario (long feeds, recommender carousels) but not universal | Domain-specific patterns: virtualization (only matters at scale), prefetch budgeting (only matters on slow networks), summary/detail split (only matters for carousels) |
| **MEDIUM** | Localized correctness or render efficiency; high frequency, contained blast radius | Per-component patterns: stable keys, scoped Suspense, optimistic mutations |
| **LOW-MEDIUM** | Micro-optimization on hot paths | Reserved for very specific hot loops; rarely used in this skill |
| **LOW** | Edge case or expert pattern | Reserved for niche scenarios; rarely used in this skill |
A rule's tier is independent of its category's tier — a CRITICAL category can contain HIGH or MEDIUM rules, and vice versa. The category tier reflects the *typical* severity of issues in that domain; individual rule tiers reflect the specific impact of each pattern.
---
## 1. Request Orchestration (orch)
**Impact:** CRITICAL
**Description:** How concurrent fetches are coordinated — parallelism vs waterfalls, batching of N+1 fan-out, in-flight deduplication, and lifting fetches into route loaders. Wrong orchestration multiplies latency (sequential awaits) and multiplies backend load (every child component independently fetching). The single biggest source of backend overload from React apps.
## 2. Cache Strategy (cache)
**Impact:** CRITICAL
**Description:** How fetched data is keyed, stored, and shared across components — deterministic cache keys, normalized graph cache for shared entities (the same product appearing in three carousels), stale-while-revalidate semantics, `staleTime`, and selector-scoped subscriptions. A bad key strategy creates a cache miss for every user; a flat cache forces N requests for the same entity reused N times.
## 3. Backend Protection (protect)
**Impact:** CRITICAL
**Description:** Guardrails that prevent the client from overwhelming the backend — concurrency caps on fan-out, request collapsing for identical in-flight calls, debounce/throttle on user-driven triggers, and stampede protection (jittered retries, cache-warming locks). Without these, a refresh, a viral page, or a brief backend slowdown becomes a self-DDoS.
## 4. Prefetch & Hydration (prefetch)
**Impact:** HIGH
**Description:** Eager fetching strategies that start requests before the component needs them — route loaders, hover/intent prefetch, idle-time and viewport-triggered prefetch, and server-rendered cache hydration. Done right, this shifts latency out of the user's critical path; done wrong, it doubles backend load by prefetching data the user never views.
## 5. Failure Resilience (resilience)
**Impact:** HIGH
**Description:** Patterns that keep the app working when fetches fail — exponential backoff with jitter, circuit breakers, request cancellation via `AbortController`, scoped error boundaries, and stale-cache fallback. Naive retries turn transient blips into outages by hammering the recovering service; missing cancellation leaks completed requests after navigation.
## 6. Feed & Carousel Patterns (feed)
**Impact:** MEDIUM-HIGH
**Description:** Patterns specific to feeds and recommender carousels at scale — virtualization, cursor-based pagination, infinite query dedup, splitting summary fetches from detail fetches, and viewport-triggered detail loading. These collapse hundreds of off-screen renders and hundreds of unviewed-item detail fetches into a bounded working set.
## 7. Mutation & Invalidation (mutate)
**Impact:** MEDIUM
**Description:** How writes update the cache — optimistic updates with rollback, surgical invalidation (target specific keys, not entire trees), `setQueryData` instead of refetch when the result is known, and mutation idempotency. Wrong invalidation triggers an avalanche of refetches; over-aggressive invalidation defeats the cache.
## 8. Component Patterns (render)
**Impact:** MEDIUM
**Description:** Component-level patterns that prevent render-driven re-fetches — stable query keys, scoped Suspense boundaries, fan-out caps inside `map()`, selector hooks that subscribe to only the rendered fields, and co-locating queries near the components that consume them. These collapse re-render storms and prevent components from accidentally refetching on every parent update.
references/cache-deterministic-keys.md
---
title: Build Deterministic Cache Keys
impact: CRITICAL
impactDescription: prevents accidental cache misses on every render
tags: cache, keys, deduplication, serialization
---
## Build Deterministic Cache Keys
Cache keys are compared structurally. `{ id: 1, type: 'A' }` and `{ type: 'A', id: 1 }` *look* equal but their JSON serializations differ — a serialization-based cache would store them as two entries. New object literals on every render produce a new key on every render, so the cache never hits. Canonicalize: sort keys, drop undefined values, strip irrelevant fields.
TanStack Query handles object key ordering automatically but does not strip `undefined` or sort arrays — if you pass user-controlled filters, normalize them before they become a key.
**Incorrect (filter object built inline on every render, undefined fields, unsorted IDs):**
```tsx
function ProductList({ category, minPrice }: Filters) {
// New object literal each render → new key reference each render
const filters = { category, minPrice, ids: selectedIds };
// selectedIds order varies; minPrice may be undefined when not set
const { data } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
});
// Result: ['products', {category: 'A', minPrice: undefined, ids: [3,1,2]}] ≠
// ['products', {category: 'A', ids: [1,2,3]}] — cache misses
}
```
**Correct (canonicalize: strip undefined, sort arrays, fixed shape):**
```tsx
function canonicalKey<T extends object>(obj: T): T {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => [k, Array.isArray(v) ? [...v].sort() : v])
) as T;
}
function ProductList({ category, minPrice }: Filters) {
const filters = useMemo(
() => canonicalKey({ category, minPrice, ids: selectedIds }),
[category, minPrice, selectedIds]
);
const { data } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
});
}
```
**Tag-style alternative (avoid serializing complex filters entirely):**
```tsx
// Hash the filters once, use the hash as the key
const filterHash = useMemo(() => hashFilters(filters), [filters]);
useQuery({ queryKey: ['products', filterHash], queryFn: () => fetchProducts(filters) });
```
**Warning:** never include `Date.now()`, `Math.random()`, or non-serializable values (functions, class instances) in a cache key — every render produces a fresh key and the cache becomes a memory leak.
Reference: [TanStack Query — Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys)
references/cache-normalize-shared-entities.md
---
title: Normalize Shared Entities Across Views
impact: CRITICAL
impactDescription: N-fold cache size reduction for shared entities
tags: cache, normalization, graph-cache, relay, entities
---
## Normalize Shared Entities Across Views
If a product appears in three carousels and a search result on the same page, a flat (query-keyed) cache stores it four times — and updates to "is in cart" propagate to only the one the user clicked. A normalized cache stores each entity once by ID; views hold references. Update once, all four views re-render with the new state.
This is what Relay, Apollo, RTK Query, and Normy do automatically. TanStack Query is flat by default — for high entity reuse, either layer Normy on top or denormalize-then-update via `setQueryData` after mutations.
**Incorrect (flat cache: same product cached four times):**
```tsx
// Each of these queries stores its own copy of overlapping product objects
useQuery({ queryKey: ['carousel', 'trending'], queryFn: fetchTrendingProducts });
useQuery({ queryKey: ['carousel', 'recently-viewed'], queryFn: fetchRecentProducts });
useQuery({ queryKey: ['carousel', 'recommended'], queryFn: fetchRecommendations });
useQuery({ queryKey: ['search', term], queryFn: () => searchProducts(term) });
// User mutates "add to favorites" on product #42 in trending
queryClient.setQueryData(['carousel', 'trending'], updateFavorite(/*...*/));
// → only the trending carousel re-renders; the same product in 'recently-viewed' stays stale
```
**Correct (normalize: store product once, queries hold IDs):**
```tsx
// Normalized store (Zustand/Jotai/custom) keyed by entity type + id
type Store = {
products: Map<string, Product>;
setProducts: (ps: Product[]) => void;
};
// Query stores only the IDs; rendering reads details from the store
const { data: ids } = useQuery({
queryKey: ['carousel', 'trending'],
queryFn: async () => {
const products = await fetchTrendingProducts();
productStore.setProducts(products); // dump entities into normalized store
return products.map(p => p.id); // query stores just the order
},
});
function ProductCard({ id }: { id: string }) {
const product = useProductStore(s => s.products.get(id)); // re-renders when THIS product changes
return <Card product={product!} />;
}
// One mutation, all four views update:
function favoriteProduct(id: string) {
productStore.setProducts([{ ...productStore.products.get(id)!, isFavorite: true }]);
}
```
**Alternative (Normy as a drop-in layer):**
```tsx
import { createQueryNormalizer } from '@normy/react-query';
// Normy reads response shapes, indexes by `id`, and surgically updates all queries
// that contain entities with that id. Zero per-query code.
```
**When NOT to normalize:** when entities are rarely shared between views (e.g. a settings page with unique data per route). The normalization tax isn't worth it.
Reference: [Normy — Automatic Normalization](https://github.com/klis87/normy)
references/cache-select-subscribed-fields.md
---
title: Use select to Subscribe to a Subset of Cache Data
impact: CRITICAL
impactDescription: 5-20x reduction in re-render rate
tags: cache, select, subscription, re-renders, performance
---
## Use select to Subscribe to a Subset of Cache Data
A component subscribed to `useQuery({ queryKey: ['user', id] })` re-renders any time *any* field of the user object changes — even if it only displays `user.name`. In a feed where each row subscribes to a chunky user object, an unrelated update to `user.lastSeenAt` re-renders every row. `select` transforms the cache value and the component re-renders only when the *selected* slice changes.
Same pattern in Zustand, Redux's `useSelector`, Jotai's atoms. The principle: subscribe to the minimum slice you render.
**Incorrect (subscribe to whole user, re-render on any field change):**
```tsx
function CommentAvatar({ authorId }: { authorId: string }) {
// Re-renders any time *anything* on the user changes — name, bio, lastSeenAt, isOnline...
const { data: user } = useQuery({
queryKey: ['user', authorId],
queryFn: () => fetchUser(authorId),
});
return <Avatar src={user?.avatarUrl} />; // we only need avatarUrl!
}
```
**Correct (select narrows the subscription to the rendered field):**
```tsx
function CommentAvatar({ authorId }: { authorId: string }) {
const { data: avatarUrl } = useQuery({
queryKey: ['user', authorId],
queryFn: () => fetchUser(authorId),
select: user => user.avatarUrl, // re-render only when avatarUrl changes
});
return <Avatar src={avatarUrl} />;
}
```
**Multiple consumers of the same query, each selecting different fields:**
```tsx
function useUserAvatar(id: string) {
return useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), select: u => u.avatarUrl });
}
function useUserName(id: string) {
return useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), select: u => u.name });
}
// One fetch, two narrow subscriptions — name update doesn't re-render avatar consumers.
```
**Implementation note:** `select` must produce a stable reference for the same input. Returning `{ a, b }` on every call creates a new object each time and defeats memoization — return primitives, or wrap the projector in `useCallback` and produce equal results.
Reference: [TanStack Query — Render Optimizations](https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations)
references/cache-set-stale-time.md
---
title: Set staleTime to Suppress Redundant Refetches
impact: CRITICAL
impactDescription: 5-50x reduction in refetch rate
tags: cache, stale-time, refetch, swr
---
## Set staleTime to Suppress Redundant Refetches
By default, TanStack Query treats data as "instantly stale" — a refetch fires every time the query mounts, every time a component remounts, every time the window regains focus. For data that rarely changes (a product catalog, a user profile, a list of countries), this is a 50x amplification of backend load over what's needed. `staleTime` tells the cache "trust this data for N seconds — don't refetch within that window."
Pick `staleTime` based on how often the underlying data changes, not how often the user wants to see updates. A product's name changes monthly; a stock count changes per-second.
**Incorrect (default staleTime: refetches on every mount and focus):**
```tsx
function ProductCard({ id }: { id: string }) {
// staleTime: 0 by default
// → if the user opens this card in 10 components, focuses the tab,
// navigates away and back, you'll see ~12 refetches of /products/:id
const { data } = useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
});
}
```
**Correct (staleTime tuned to data volatility):**
```tsx
function ProductCard({ id }: { id: string }) {
const { data } = useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
staleTime: 5 * 60_000, // catalog data — fine for 5 minutes
gcTime: 30 * 60_000, // keep in memory for 30 min after last use
});
}
// Different staleTime for different data classes — see [[cache-tiered-stale-fresh]]
function StockBadge({ productId }: { productId: string }) {
const { data } = useQuery({
queryKey: ['stock', productId],
queryFn: () => fetchStock(productId),
staleTime: 10_000, // inventory — refresh every 10s
});
}
```
**Global defaults (set once, override per-query when needed):**
```tsx
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s default — sensible for most app data
gcTime: 5 * 60_000,
refetchOnWindowFocus: false, // opt-in per query
},
},
});
```
**Warning:** `staleTime: Infinity` for shared mutable data leaks staleness across users. Reserve it for truly immutable data (a list of country codes).
Reference: [TanStack Query — Important Defaults](https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults)
references/cache-shared-key-factory.md
---
title: Centralize Cache Keys in a Key Factory
impact: CRITICAL
impactDescription: prevents read/write key drift
tags: cache, keys, factory, invalidation, organization
---
## Centralize Cache Keys in a Key Factory
Key drift is a silent killer: a `useQuery` reads from `['products', filters]` and a mutation invalidates `['product', filters]` (singular vs plural). The mutation succeeds, the cache appears intact, the UI shows stale data, and you spend an afternoon debugging "cache invalidation." Define keys once in a typed factory and import them everywhere — reads, writes, prefetches, invalidations all reference the same source of truth.
The factory also gives you free hierarchical invalidation: invalidating `productKeys.all` invalidates every key starting with `['products', ...]`.
**Incorrect (keys defined inline, easy to drift):**
```tsx
// somewhere in a component
useQuery({ queryKey: ['products', filters], queryFn: () => fetchProducts(filters) });
// in a mutation, six files away
queryClient.invalidateQueries({ queryKey: ['product'] }); // ❌ typo, wrong key
// invalidation looks fine in code review; runtime: nothing invalidates
```
**Correct (one source of truth):**
```tsx
// src/queries/product-keys.ts
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};
// Reads
useQuery({ queryKey: productKeys.list(filters), queryFn: () => fetchProducts(filters) });
useQuery({ queryKey: productKeys.detail(id), queryFn: () => fetchProduct(id) });
// Mutation — surgical invalidation, no string typos possible
useMutation({
mutationFn: updateProduct,
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: productKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: productKeys.lists() }); // all list views
},
});
// Logout — nuke all product queries
queryClient.removeQueries({ queryKey: productKeys.all });
```
**Benefits:**
- Type safety: TypeScript catches misuse at compile time
- Refactor confidence: rename a key, every reference updates with editor rename
- Hierarchical invalidation: invalidate at any level (`all` > `lists()` > `list(filters)`)
- Single grep location to audit cache structure
Reference: [TanStack Query — Effective React Query Keys](https://tkdodo.eu/blog/effective-react-query-keys)
references/cache-stale-while-revalidate.md
---
title: Use Stale-While-Revalidate for Instant Renders
impact: CRITICAL
impactDescription: 0ms perceived wait for cached data
tags: cache, swr, stale-while-revalidate, ux
---
## Use Stale-While-Revalidate for Instant Renders
The SWR pattern (RFC 5861): when stale data exists, return it instantly *and* fetch fresh data in the background. The user sees the page immediately, then the cards smoothly update if the fresh data differs. The alternative — showing a skeleton while refetching — punishes the user for already having the data.
Both SWR and TanStack Query implement this; it's the default behavior when `staleTime` has elapsed but `gcTime` hasn't. The pattern's superpower: navigating back to a list you've seen renders the list immediately rather than the loading state.
**Incorrect (block on revalidation — back-button shows skeleton):**
```tsx
function ProductList() {
const { data, isLoading } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
// No cached data exposed during revalidation
});
// After staleTime elapses, isLoading flips true on next mount even though we have data
if (isLoading) return <Skeleton />; // jarring flicker on every revisit
return <List products={data!} />;
}
```
**Correct (render stale instantly, fade in fresh):**
```tsx
function ProductList() {
const { data, isFetching, isPlaceholderData } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 60_000, // 1 min "fresh" window
placeholderData: keepPreviousData, // for paginated/filtered queries
});
// First mount: data is undefined → show skeleton
// Subsequent mounts: data is the cached value, isFetching may be true → render instantly
if (!data) return <Skeleton />;
return (
<>
<List products={data} className={isFetching ? 'opacity-90' : ''} />
{isFetching && <RefreshIndicator />}
</>
);
}
```
**For filtered/paginated lists (keep previous results during refetch):**
```tsx
const { data, isPlaceholderData } = useQuery({
queryKey: ['products', { filter }],
queryFn: () => fetchProducts(filter),
placeholderData: keepPreviousData, // when filter changes, show old results until new ones land
});
// Filtering doesn't flash an empty state; only after new data resolves does the list update.
```
**Server-side equivalent (HTTP header):**
```http
Cache-Control: public, max-age=0, stale-while-revalidate=600
# Browser/CDN serves stale data instantly and revalidates in the background up to 10 min
```
Reference: [Vercel — Stale-While-Revalidate](https://web.dev/articles/stale-while-revalidate) | [RFC 5861](https://datatracker.ietf.org/doc/html/rfc5861)
references/cache-tiered-stale-fresh.md
---
title: Tier staleTime by Data Volatility
impact: HIGH
impactDescription: reduces stale-data refetches 10-100×
tags: cache, stale-time, tiers, volatility
---
## Tier staleTime by Data Volatility
One global `staleTime` is a compromise — too high for inventory (shows out-of-date stock), too low for the country list (refetched needlessly). Classify your data by volatility class and assign each class a `staleTime`. Real-time data (stock, price) gets seconds; user profile gets minutes; catalog/lookup data gets hours.
This tiering is also what HTTP caching does well — `Cache-Control: max-age` per endpoint type. Mirror that policy in your client cache.
**Incorrect (one global staleTime — fights everything):**
```tsx
new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } },
});
// Inventory shown 30s stale → user adds to cart, gets "out of stock" at checkout
// Country list refetched every 30s → 100k pointless requests/day across users
```
**Correct (tiered, per data class):**
```ts
// src/queries/stale-tiers.ts
export const STALE = {
realtime: 5_000, // 5s — stock, live price, online status
fresh: 30_000, // 30s — feed, notifications, cart contents
warm: 5 * 60_000, // 5m — user profile, settings, preferences
cold: 60 * 60_000, // 1h — product details, posts, comments
static: 24 * 60 * 60_000, // 24h — country list, categories, tag taxonomy
} as const;
useQuery({ queryKey: ['stock', id], queryFn: () => fetchStock(id), staleTime: STALE.realtime });
useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), staleTime: STALE.warm });
useQuery({ queryKey: ['countries'], queryFn: fetchCountries, staleTime: STALE.static });
```
**Pair with server Cache-Control headers for full-stack consistency:**
```ts
// API route handler
response.headers.set('Cache-Control', 'public, max-age=5, stale-while-revalidate=30'); // stock
response.headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=600'); // user
response.headers.set('Cache-Control', 'public, max-age=86400, immutable'); // countries
```
**How to pick a tier:** look at the underlying source of truth. If it's a metric that updates per-second, use `realtime`. If it's a user-editable field, `warm` to `cold`. If it's reference data deployed with the app, `static`.
Reference: [TkDodo — staleTime vs gcTime](https://tkdodo.eu/blog/inside-react-query)
references/feed-bounded-working-set.md
---
title: Bound the In-Memory Working Set on Long Feeds
impact: MEDIUM-HIGH
impactDescription: prevents unbounded memory growth on infinite scroll
tags: feed, memory, working-set, infinite-scroll, eviction
---
## Bound the In-Memory Working Set on Long Feeds
A user who scrolls through 50 pages of a feed accumulates 50 pages × 30 items = 1500 cached items, even with virtualization. The DOM is bounded by virtualization, but the JS heap is not — image objects, cached query results, and normalized entity records grow without limit. On mobile, this triggers OOM kills; on desktop, GC pauses cause scroll jank.
Bound the cache: after page N+5, drop the oldest pages from the infinite-query cache and the entity store. The user can always re-scroll; the entities they care about (favorites, viewed items) are kept separately.
**Incorrect (unbounded growth — JS heap balloons on long scroll):**
```tsx
function Feed() {
const { data } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeedPage(pageParam),
initialPageParam: null,
getNextPageParam: last => last.nextCursor,
// No maxPages — pages accumulate forever
});
}
```
**Correct (bounded with maxPages — old pages drop as new ones load):**
```tsx
function Feed() {
const { data } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeedPage(pageParam),
initialPageParam: null,
getNextPageParam: last => last.nextCursor,
getPreviousPageParam: first => first.prevCursor,
maxPages: 8, // keep at most 8 pages × 30 items = 240 items in memory
});
// When fetchNextPage exceeds maxPages, the oldest page is dropped from data.pages
// If the user scrolls back, fetchPreviousPage refetches it
}
```
**Pair virtualization with cache eviction (memory-bounded scroll without losing position):**
```tsx
function BoundedFeed() {
const parentRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, fetchPreviousPage, hasNextPage, hasPreviousPage } =
useInfiniteQuery({ /* ...with maxPages: 8, getPreviousPageParam ... */ });
const allItems = data?.pages.flatMap(p => p.items) ?? [];
const virtualizer = useVirtualizer({
count: allItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120,
overscan: 5,
// When the user scrolls UP past the start of cached pages, fetch previous
onChange: (instance) => {
const first = instance.getVirtualItems()[0];
if (first && first.index < 5 && hasPreviousPage) fetchPreviousPage();
},
});
}
```
**For normalized entity stores (cap by recency + pinning):**
```ts
// Pseudocode: LRU eviction with pinned items
class EntityStore<T extends { id: string }> {
private items = new Map<string, T>();
private order: string[] = []; // most-recent first
private pinned = new Set<string>(); // never evict (favorites, recently-viewed)
constructor(private maxSize: number) {}
set(id: string, value: T) {
this.items.set(id, value);
this.order = [id, ...this.order.filter(i => i !== id)];
this.evict();
}
pin(id: string) { this.pinned.add(id); }
private evict() {
while (this.order.length > this.maxSize) {
const candidate = [...this.order].reverse().find(id => !this.pinned.has(id));
if (!candidate) break;
this.items.delete(candidate);
this.order = this.order.filter(i => i !== candidate);
}
}
}
```
**Mobile-specific consideration:** iOS Safari kills tabs at ~1GB JS heap. Native apps see OS-level memory warnings at ~150MB. Bound the working set aggressively on mobile: `maxPages: 4` instead of 8, no large blurred-image placeholders.
**Image-specific cleanup:** even when items drop from the data cache, the browser may keep image bitmaps. For very long-running feeds, periodically clear `URL.revokeObjectURL` for blob: URLs you created, and avoid keeping references to image elements.
Reference: [TanStack Query — maxPages](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries#what-if-i-want-to-limit-the-number-of-pages)
references/feed-cursor-pagination.md
---
title: Use Cursor Pagination over Offset
impact: MEDIUM-HIGH
impactDescription: prevents skip/duplicate items as the list shifts
tags: feed, cursor, pagination, offset, consistency
---
## Use Cursor Pagination over Offset
Offset pagination (`?page=2&size=20` or `LIMIT 20 OFFSET 40`) has two problems at scale: (1) the backend re-scans rows 0-39 just to return rows 40-59 — `OFFSET 100000` is brutal on the database; (2) when new items are inserted between page fetches (very common in feeds), the user sees duplicate or skipped items because page boundaries shift. Cursor pagination uses an opaque cursor pointing to "the item after which to continue" — stable across inserts, indexable, fast.
Use cursors for any feed where new items can appear between page fetches.
**Incorrect (offset pagination — duplicates and skips on inserts):**
```ts
async function fetchFeedPage(page: number): Promise<{ items: Post[]; nextPage: number | null }> {
const res = await fetch(`/api/feed?page=${page}&size=20`);
return res.json();
}
// Backend: SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET ${page*20}
// User loads page 1 (newest 20 posts), then 5 new posts come in,
// then user loads page 2 — they see the last 5 from page 1 again
```
**Correct (cursor pagination — stable across inserts):**
```ts
type FeedPage = { items: Post[]; nextCursor: string | null };
async function fetchFeedPage(cursor: string | null): Promise<FeedPage> {
const params = new URLSearchParams({ size: '20' });
if (cursor) params.set('cursor', cursor);
const res = await fetch(`/api/feed?${params}`);
return res.json();
}
// Backend: SELECT * FROM posts WHERE created_at < ${cursor_date} ORDER BY created_at DESC LIMIT 20
// New inserts at the top don't shift the cursor's anchor point
```
**With TanStack Query's useInfiniteQuery:**
```tsx
function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeedPage(pageParam),
initialPageParam: null as string | null,
getNextPageParam: lastPage => lastPage.nextCursor,
});
const allItems = data?.pages.flatMap(p => p.items) ?? [];
// Items deduplicate cleanly across pages because cursors don't overlap
}
```
**Backend implementation patterns:**
```sql
-- Cursor as a composite: (created_at, id) for tie-breaking
SELECT * FROM posts
WHERE (created_at, id) < ($cursorTime, $cursorId)
ORDER BY created_at DESC, id DESC
LIMIT 21; -- fetch one extra to detect "has next"
-- Encode cursor opaquely so clients can't manipulate it:
-- cursor = base64({ created_at, id })
```
**For real-time feeds (poll the "head" cursor):**
```tsx
// Periodically check for new items above the top — a "newer than cursor X" query
const { data: newItems } = useQuery({
queryKey: ['feed', 'newer-than', headCursor],
queryFn: () => fetchNewerThan(headCursor),
refetchInterval: 30_000,
enabled: !!headCursor,
});
// Surface as "5 new posts — tap to view" badge instead of inserting mid-scroll
```
**When offset is fine:**
- Stable archives (blog post lists that don't change)
- Admin tables with deterministic sort + low insert rate
- Small data sets where `OFFSET` performance doesn't matter
**Pitfall (don't expose internal DB IDs as cursors):** if your cursor is `?cursor=12345`, users learn that posts are sequential integers and probe gaps. Use opaque, signed cursors.
Reference: [Use The Index, Luke — Pagination](https://use-the-index-luke.com/sql/partial-results/fetch-next-page) | [TanStack Query — Infinite Queries](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries)
references/feed-image-lazy-and-sized.md
---
title: Defer Off-Screen Feed Images with Explicit Dimensions
impact: MEDIUM-HIGH
impactDescription: prevents layout shift and saves 70-90% image bandwidth
tags: feed, images, lazy-loading, cls, dimensions
---
## Defer Off-Screen Feed Images with Explicit Dimensions
A feed of 100 items each with a 200kb hero image means a 20MB download if everything loads up front — but most users scroll through ~10 items. Native `loading="lazy"` defers off-screen images until the user scrolls near them. Explicit `width` and `height` (or `aspect-ratio`) prevent layout shift when each image arrives — without dimensions, the page jumps as images load, breaking infinite scroll.
`<img loading="lazy">` is supported in all evergreen browsers. For finer control, combine `IntersectionObserver` with a low-quality placeholder.
**Incorrect (eager-load all images, no dimensions, layout shifts as they arrive):**
```tsx
function FeedItem({ item }: { item: Item }) {
return (
<article>
<img src={item.heroUrl} alt={item.title} />
{/* No width/height — height is 0 until image loads, then expands and pushes everything */}
<h3>{item.title}</h3>
</article>
);
}
// 100 items × 200kb = 20MB download on mount; CLS score destroyed
```
**Correct (lazy-loaded with explicit aspect ratio):**
```tsx
function FeedItem({ item }: { item: Item }) {
return (
<article>
<img
src={item.heroUrl}
alt={item.title}
width={1200}
height={630}
loading="lazy"
decoding="async"
style={{ aspectRatio: '1200 / 630', objectFit: 'cover', width: '100%', height: 'auto' }}
/>
<h3>{item.title}</h3>
</article>
);
}
// Only images near viewport download; layout stable as images arrive
```
**With Next.js Image (optimized + lazy + responsive):**
```tsx
import Image from 'next/image';
function FeedItem({ item }: { item: Item }) {
return (
<article>
<Image
src={item.heroUrl}
alt={item.title}
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, 720px" // serve appropriately sized image
loading="lazy"
placeholder="blur"
blurDataURL={item.heroBlur} // tiny inline placeholder (~100 bytes)
/>
</article>
);
}
```
**For the first 1-3 items above the fold, eager-load:**
```tsx
{items.map((item, i) => (
<FeedItem
key={item.id}
item={item}
priority={i < 2} // first two get loading="eager" and fetchpriority="high"
/>
))}
```
**For carousels (off-screen but DOM-rendered):**
```tsx
// Native loading="lazy" only triggers on viewport intersection;
// horizontally-scrolled carousel items count as "in viewport" even when scrolled away
// → use IntersectionObserver on the carousel container instead
```
**Avoid CLS pitfalls:**
| Anti-pattern | Fix |
|--------------|-----|
| `<img src="..." />` with no dimensions | Add `width` + `height` attributes |
| Skeleton without matching dimensions | Skeleton must occupy the same space as the loaded image |
| Late-arriving CSS that shrinks image | Set dimensions in HTML, not just CSS |
| Cross-origin images blocked by CORS while measuring | Use `crossorigin="anonymous"` if you'll need bitmap access |
Reference: [MDN — Lazy loading](https://developer.mozilla.org/en-US/docs/Web/Performance/Lazy_loading) | [web.dev — Cumulative Layout Shift](https://web.dev/articles/cls)
references/feed-multi-carousel-isolation.md
---
title: Isolate Failure Across a Feed of Carousels
impact: MEDIUM-HIGH
impactDescription: prevents one failing carousel from breaking the homepage
tags: feed, carousel, error-boundary, isolation, suspense
---
## Isolate Failure Across a Feed of Carousels
A homepage feed of 6 carousels (trending, recommended, recently-viewed, "people also bought", seasonal, personalized) means 6 independent recommender pipelines. At any given moment, one of them is degraded — a model is being redeployed, a data source is rate-limited, a personalization service is on call. Without isolation, *one* failing carousel cascades to break the whole feed (one Suspense root → all wait for the slowest; one ErrorBoundary root → one failure crashes everything).
The pattern: wrap each carousel in its own `ErrorBoundary` + `Suspense`, tier carousels by importance (critical, important, decorative), and apply tier-appropriate fallbacks. Combine with bounded concurrency at the fetch layer so 6 parallel summary fetches don't drown the user-initiated request happening at the same time.
This is the multi-section version of [[resilience-scoped-error-boundaries]] and [[render-suspense-per-section]], with the failure-tier logic from [[resilience-graceful-degradation]].
**Incorrect (single boundary — one failure kills the entire feed):**
```tsx
function HomepageFeed() {
return (
<ErrorBoundary fallback={<FullPageError />}>
<Suspense fallback={<FullFeedSkeleton />}>
<ContinueWatchingCarousel />
<RecommendedCarousel />
<TrendingCarousel />
<RecentlyViewedCarousel /> {/* this one errors */}
<SeasonalCarousel />
{/* → entire homepage now shows FullPageError because one decorative
carousel had a 500. User sees nothing. */}
</Suspense>
</ErrorBoundary>
);
}
```
**Correct (per-carousel isolation with tier-appropriate fallbacks):**
```tsx
type Tier = 'critical' | 'important' | 'decorative';
const FEED: Array<{ kind: string; title: string; tier: Tier }> = [
{ kind: 'continue-watching', title: 'Continue watching', tier: 'critical' },
{ kind: 'recommended', title: 'Recommended for you', tier: 'important' },
{ kind: 'trending', title: 'Trending now', tier: 'important' },
{ kind: 'recently-viewed', title: 'Recently viewed', tier: 'decorative' },
{ kind: 'seasonal', title: 'Seasonal picks', tier: 'decorative' },
];
function HomepageFeed() {
return (
<div className="flex flex-col gap-12">
{FEED.map((cfg, i) => (
<ErrorBoundary
key={cfg.kind}
onError={(e) => reportError({ section: cfg.kind, tier: cfg.tier, error: e })}
fallbackRender={({ resetErrorBoundary }) => {
if (cfg.tier === 'decorative') return null; // silent
if (cfg.tier === 'important') return <MiniError onRetry={resetErrorBoundary} />;
return <FullError title={cfg.title} onRetry={resetErrorBoundary} />;
}}
>
<Suspense fallback={<CarouselSkeleton title={cfg.title} />}>
{i < 2
? <Carousel kind={cfg.kind} /> // above the fold — eager
: <DeferredCarousel kind={cfg.kind} />} // below the fold — viewport-triggered
</Suspense>
</ErrorBoundary>
))}
</div>
);
}
```
**Eager and deferred carousel variants:**
```tsx
// Above-the-fold — suspends on first fetch
function Carousel({ kind }: { kind: string }) {
const { data } = useSuspenseQuery({
queryKey: ['carousel', kind, 'summaries'],
queryFn: ({ signal }) => fetchCarouselSummaries(kind, { signal }),
staleTime: 5 * 60_000,
});
return <Track items={data} />;
}
// Below-the-fold — defer mounting (and fetching) until in viewport
function DeferredCarousel({ kind }: { kind: string }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const obs = new IntersectionObserver(
([e]) => e.isIntersecting && setVisible(true),
{ rootMargin: '300px' }
);
if (ref.current) obs.observe(ref.current);
return () => obs.disconnect();
}, []);
if (!visible) return <div ref={ref} style={{ height: 280 }} />; // reserve space
return <div ref={ref}><Carousel kind={kind} /></div>;
}
```
**Tier fallbacks table:**
| Tier | On failure | Why |
|------|-----------|-----|
| `critical` | Full error UI with retry; visible | User came specifically for this content (Continue Watching) — they need to know it failed |
| `important` | Minimal retry placeholder | Visible degradation maintains trust without breaking layout |
| `decorative` | `null` (silently hidden) | Failure shouldn't punish the user for absent ad-side rails |
**Concurrency guardrail at the fetch layer:**
Six carousels mounting at once means six parallel summary requests, plus dozens of detail prefetches if items hover-prefetch. Cap parallelism so the feed degrades to "smooth but a bit slower" instead of "all timing out":
```ts
// At fetch wiring time — see [[protect-concurrency-limit-fanout]]
import { collapsedFetch } from './request-collapser';
// collapsedFetch caps at 6 concurrent requests + dedupes identical GETs
async function fetchCarouselSummaries(kind: string, init: { signal: AbortSignal }) {
const res = await collapsedFetch(`/api/carousels/${kind}/summaries`, init);
return res.json();
}
```
**Render-priority ordering matters:**
Even with per-section Suspense, *render order* in the JSX determines reveal order in the SSR streaming case. Put critical carousels first — they get the first reserved layout slot and the first streamable chunk. Decorative carousels last, so their slow data sources don't push the critical ones below the fold.
**Observability rule:** silent failures are not invisible failures. Decorative carousels rendering `null` on failure must still emit a structured error to your logging stack (`onError={reportError}`) so an outage is detectable. A degraded recommender that quietly serves zero items for a week is a silent revenue leak.
**When NOT to use this pattern:**
- Single-carousel pages — the overhead of tiering buys nothing
- Synchronously-loaded feeds where every carousel ships in the initial HTML payload — Suspense isn't doing meaningful work
- Critical compliance flows where ANY missing data is a hard fail — there `tier: critical` isn't strong enough; refuse to render the page at all
**Pair with [[feed-split-summary-from-detail]]:** each carousel's summary fetch returns lightweight payloads; viewport-triggered detail fetches keep working-set bounded across the whole feed.
Reference: [react-error-boundary](https://github.com/bvaughn/react-error-boundary) | [Vercel — Streaming with Suspense](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming)
references/feed-split-summary-from-detail.md
---
title: Split Carousel Summaries from Item Details
impact: MEDIUM-HIGH
impactDescription: reduces initial carousel payload 5-20×
tags: feed, carousel, summary, detail, lazy
---
## Split Carousel Summaries from Item Details
A recommender carousel needs *very little* per item to render: ID, thumbnail URL, title, often a price. But the same backend endpoint often returns the *full* product object — description, variants, reviews, related products — bloating the payload. Split the API: one endpoint returns lightweight summaries (10x smaller); detail endpoints fetch the rest on hover, click, or viewport.
For carousel-heavy pages, this is the difference between a 200kb response and a 20kb response — and the difference between fetching 30 detail-laden objects for items 90% of users never click.
**Incorrect (carousel fetches full objects up front):**
```tsx
function RecommendedCarousel() {
const { data } = useQuery({
queryKey: ['recs', 'full'],
queryFn: fetchFullRecommendations, // returns full product objects: 80kb for 30 items
});
return (
<Carousel>
{data?.map(p => (
<CarouselCard key={p.id} thumbnail={p.thumbnail} title={p.title} />
// Each card uses 3 fields out of ~25 — the other 22 fields are wasted bytes
))}
</Carousel>
);
}
```
**Correct (summary + on-demand detail):**
```tsx
// Summary type: just what the card renders
type ProductSummary = { id: string; thumbnail: string; title: string; price: number };
function RecommendedCarousel() {
const { data: summaries } = useQuery({
queryKey: ['recs', 'summary'],
queryFn: fetchRecommendationsSummary, // returns ProductSummary[]: 5kb for 30 items
});
return (
<Carousel>
{summaries?.map(s => (
<CarouselCard
key={s.id}
summary={s}
// Pre-fetch full detail on hover; quick-look popover uses it instantly
onMouseEnter={() => queryClient.prefetchQuery({
queryKey: ['product', s.id],
queryFn: () => fetchProduct(s.id),
staleTime: 60_000,
})}
/>
))}
</Carousel>
);
}
// When the user clicks for a quick-look
function QuickLook({ productId }: { productId: string }) {
const { data: product } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
});
return <DetailView product={product} />;
}
```
**API endpoint design pattern:**
```text
GET /api/recommendations
→ returns [{ id, thumbnail, title, price }, ...] # 5kb for 30 items
GET /api/products/:id
→ returns full product object # 4kb for one item
GET /api/products?ids=a,b,c
→ returns full product objects, bulk # 4kb × N for batched detail
```
**For carousels with viewport-triggered detail (only items the user sees get detailed):**
```tsx
function CarouselCard({ summary }: { summary: ProductSummary }) {
const cardRef = useRef<HTMLDivElement>(null);
const [inViewport, setInViewport] = useState(false);
useEffect(() => {
const obs = new IntersectionObserver(([e]) => setInViewport(e.isIntersecting));
if (cardRef.current) obs.observe(cardRef.current);
return () => obs.disconnect();
}, []);
// Only fetch detail for items the user actually scrolls past
const { data: detail } = useQuery({
queryKey: ['product', summary.id],
queryFn: () => fetchProduct(summary.id),
enabled: inViewport,
staleTime: 60_000,
});
return <div ref={cardRef}>{/* render summary; show detail extras when present */}</div>;
}
```
**When NOT to split:** if the summary is already most of the object (a tweet has very few fields beyond the visible content), the split costs more than it saves.
**Cache normalization angle:** when summaries and details overlap on the same fields (title, thumbnail), pair this with [[cache-normalize-shared-entities]] so fetching the detail upgrades the summary in place rather than holding two copies.
Reference: [GraphQL — Fragments and Field Selection](https://graphql.org/learn/queries/#fragments) | [Vercel — Optimizing Data Fetching](https://vercel.com/blog/everything-about-data-fetching-in-nextjs)
references/feed-stable-keys-across-pages.md
---
title: Use Stable Item Keys Across Paginated Pages
impact: MEDIUM
impactDescription: prevents full-list re-render on each new page
tags: feed, keys, pagination, react-keys, reconciliation
---
## Use Stable Item Keys Across Paginated Pages
In a paginated/infinite feed, every time a new page arrives the parent re-renders with a longer items array. If item keys are stable (`item.id`), React reconciles in O(N+1) — existing rows reuse their fibers, only the new ones mount. If keys are index-based (`key={i}`), every row "moves" when the array grows from prepending or when virtual indices shift, triggering a full unmount/remount of every visible row.
Mount/unmount cycles re-trigger effects (refetches), re-create child query subscriptions, and reset internal state (form inputs, scroll positions inside items). For high-frequency feeds, key instability is a silent 5-10× cost multiplier on scroll updates.
**Incorrect (index keys — entire list unmounts/remounts on new pages):**
```tsx
function Feed() {
const { data } = useInfiniteQuery({/* ... */});
const items = data?.pages.flatMap(p => p.items) ?? [];
return items.map((item, i) => <FeedRow key={i} item={item} />);
// After a "prepend new items" event, every existing row sees a different key
// → every row remounts → every embedded useQuery refires → every form resets
}
```
**Correct (entity ID keys — stable across mutations):**
```tsx
function Feed() {
const { data } = useInfiniteQuery({/* ... */});
const items = data?.pages.flatMap(p => p.items) ?? [];
return items.map(item => <FeedRow key={item.id} item={item} />);
// Even when 5 new items are prepended, the existing rows keep their fibers
}
```
**For items without natural IDs (e.g., aggregated rows in a report):**
```tsx
// Generate a stable composite key on the data side, not in render
const itemsWithKeys = useMemo(
() => items.map(item => ({ ...item, key: `${item.userId}-${item.date}` })),
[items]
);
// In render:
itemsWithKeys.map(item => <Row key={item.key} item={item} />);
```
**De-duplicate items across pages (cursor pagination can produce repeats at page edges):**
```tsx
const items = useMemo(() => {
const all = data?.pages.flatMap(p => p.items) ?? [];
// Dedup by id, keep first occurrence
const seen = new Set<string>();
return all.filter(item => seen.has(item.id) ? false : (seen.add(item.id), true));
}, [data]);
```
**Symptoms of unstable keys (debug checklist):**
- Forms inside list items lose their input on every refresh
- Embedded queries refetch on every page load
- Animations restart on every parent update
- React DevTools shows mount/unmount on rows that visually stayed in place
**Don't use random IDs:** `key={Math.random()}` or `key={crypto.randomUUID()}` generated in render means every render produces new keys → every row remounts every render. This is the most extreme version of the bug.
Reference: [React — Keeping list items in order with key](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key)
references/feed-virtualize-long-lists.md
---
title: Virtualize Long Lists Beyond ~50 Items
impact: MEDIUM-HIGH
impactDescription: 10-100× fewer DOM nodes
tags: feed, virtualization, tanstack-virtual, performance
---
## Virtualize Long Lists Beyond ~50 Items
Mounting 2000 list items creates 2000 React fibers, 2000 DOM nodes, and 2000 subscriptions to anything those items use. Scrolling becomes janky; updates cascade through every row. Virtualization renders only the items currently in (or near) the viewport — typically 20-50 — and recycles them as the user scrolls.
The threshold is around 50 items for medium-complexity rows; below that, virtualization's overhead isn't worth it. Above 200, it becomes essential.
**Incorrect (render all items — DOM blows up):**
```tsx
function Feed({ items }: { items: Item[] }) {
// 2000 items = 2000 DOM rows. Initial paint ~800ms; scroll FPS plummets.
return items.map(item => <FeedRow key={item.id} item={item} />);
}
```
**Correct (virtualize with TanStack Virtual):**
```tsx
import { useVirtualizer } from '@tanstack/react-virtual';
function Feed({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120, // approximate row height
overscan: 5, // render 5 extra above/below viewport for smoother scroll
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(vRow => (
<div
key={vRow.key}
data-index={vRow.index}
ref={virtualizer.measureElement} // dynamic row heights work too
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vRow.start}px)`,
}}
>
<FeedRow item={items[vRow.index]} />
</div>
))}
</div>
</div>
);
}
// Only ~25 rows mounted regardless of total. Constant memory; smooth scroll.
```
**Combine with infinite query for paginated feeds:**
```tsx
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({/* ... */});
const allItems = useMemo(
() => data?.pages.flatMap(p => p.items) ?? [],
[data]
);
const virtualizer = useVirtualizer({
count: hasNextPage ? allItems.length + 1 : allItems.length, // +1 sentinel slot
getScrollElement: () => parentRef.current,
estimateSize: () => 120,
});
// In the row renderer:
const vRow = virtualizer.getVirtualItems().at(-1);
useEffect(() => {
if (!vRow) return;
if (vRow.index >= allItems.length - 1 && hasNextPage) fetchNextPage();
}, [vRow?.index, allItems.length, hasNextPage]);
```
**When NOT to virtualize:**
- Lists shorter than 50 items — virtualization overhead isn't worth it
- Items with very irregular content (rich text articles) — measurement is expensive
- Content that needs to print or be SEO-indexed — virtualized items are invisible to crawlers and `Ctrl-F`
**Trade-offs to plan for:**
- `Ctrl-F` doesn't find off-screen items by default (consider headless mode for print)
- Anchor links and scroll-into-view require `scrollToIndex()` calls
- Screen readers need ARIA live regions or `aria-rowcount` to understand virtualization
Reference: [TanStack Virtual](https://tanstack.com/virtual/latest)
references/mutate-cancel-queries-on-mutate.md
---
title: Cancel In-Flight Queries Before Mutating Their Cache
impact: MEDIUM
impactDescription: prevents race conditions between mutation and refetch
tags: mutate, cancel-queries, race-conditions, optimistic
---
## Cancel In-Flight Queries Before Mutating Their Cache
Optimistic update writes `liked: true` into the cache. 200ms later, a background refetch (started before the mutation) lands with `liked: false` — overwriting your optimistic update and "un-liking" the post in the UI even though the mutation succeeded. The race is silent and intermittent. Fix: cancel any in-flight queries for the affected key inside `onMutate`, before writing the optimistic value.
This pattern is most critical with optimistic updates ([[mutate-optimistic-updates-with-rollback]]), but also applies any time `setQueryData` is called near in-flight refetches.
**Incorrect (no cancellation — background refetch can clobber optimistic update):**
```tsx
const like = useMutation({
mutationFn: likePost,
onMutate: ({ postId }) => {
const previous = queryClient.getQueryData<Post>(['post', postId]);
queryClient.setQueryData<Post>(['post', postId], { ...previous!, liked: true });
return { previous };
// 🚨 A refetch that started 100ms ago is still in flight.
// When it resolves, it overwrites our optimistic update with stale server state.
},
});
```
**Correct (cancel in-flight queries first):**
```tsx
const like = useMutation({
mutationFn: likePost,
onMutate: async ({ postId }) => {
// Cancel any refetches for this query so they don't overwrite our optimistic value
await queryClient.cancelQueries({ queryKey: ['post', postId] });
const previous = queryClient.getQueryData<Post>(['post', postId]);
queryClient.setQueryData<Post>(['post', postId], { ...previous!, liked: true });
return { previous };
},
onError: (_e, { postId }, ctx) => {
if (ctx) queryClient.setQueryData(['post', postId], ctx.previous);
},
onSettled: (_d, _e, { postId }) => {
// Now it's safe to refetch — the mutation result is settled
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
```
**Why this matters at scale:** any query with `refetchOnWindowFocus`, `refetchOnReconnect`, polling, or that's pending from a recent invalidation has an in-flight request. In a typical app, several queries are in flight at any moment — `cancelQueries` makes "before mutation" a known-quiet point.
**Cancellation requires AbortSignal forwarding:** for cancel to actually abort the network request, your queryFn must accept and use the `AbortSignal`. Otherwise cancel just unsubscribes the result; the request keeps running and consumes backend resources.
```ts
// Required — forward the signal
const fetchPost = ({ signal }: { signal: AbortSignal }) =>
fetch(`/api/posts/${id}`, { signal }).then(r => r.json());
```
See [[resilience-abort-on-unmount]] for full AbortSignal patterns.
**Scope the cancellation to what you're about to mutate:**
```ts
// Too broad — cancels unrelated queries
await queryClient.cancelQueries(); // ❌
// Targeted — only the affected key
await queryClient.cancelQueries({ queryKey: ['post', postId] }); // ✅
```
**For list-affecting mutations (cancel list and detail):**
```tsx
const updatePost = useMutation({
onMutate: async (input) => {
await Promise.all([
queryClient.cancelQueries({ queryKey: ['post', input.id] }),
queryClient.cancelQueries({ queryKey: ['posts'] }), // cancel list refetches
]);
// ... snapshot + optimistic updates
},
});
```
Reference: [TanStack Query — Cancel Queries on Mutate](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates#updating-a-single-todo)
references/mutate-optimistic-updates-with-rollback.md
---
title: Apply Optimistic Updates with Rollback on Failure
impact: MEDIUM
impactDescription: eliminates 200-800ms perceived mutation latency
tags: mutate, optimistic, rollback, ux, on-mutate
---
## Apply Optimistic Updates with Rollback on Failure
When the user clicks "like", waiting 300ms for the server's confirmation before updating the heart icon makes the app feel sluggish. Optimistic updates: apply the change to the cache instantly, fire the mutation in the background, and if it fails, roll back. Most mutations succeed — so most users see an instant response — and the rare failure rewinds cleanly.
Pair it with [[resilience-no-auto-retry-mutations]]: optimistic UX + idempotency-keyed mutations + bounded retries is the safe combination.
**Incorrect (block on server — heart turns red 300ms later):**
```tsx
const like = useMutation({
mutationFn: likePost,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['post', postId] }),
});
<HeartIcon
liked={post.liked}
onClick={() => like.mutate({ postId })}
loading={like.isPending} // user sees a spinner for 300ms
/>
```
**Correct (instant feedback with snapshot/rollback):**
```tsx
const like = useMutation({
mutationFn: likePost,
onMutate: async ({ postId }) => {
// 1. Cancel in-flight queries for this post so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['post', postId] });
// 2. Snapshot the current value (for rollback)
const previous = queryClient.getQueryData<Post>(['post', postId]);
// 3. Optimistically update
if (previous) {
queryClient.setQueryData<Post>(['post', postId], {
...previous,
liked: true,
likeCount: previous.likeCount + 1,
});
}
return { previous }; // returned context goes to onError / onSettled
},
onError: (err, { postId }, ctx) => {
// 4. Rollback to the snapshot on failure
if (ctx?.previous) queryClient.setQueryData(['post', postId], ctx.previous);
toast.error('Failed to like — please try again');
},
onSettled: (data, error, { postId }) => {
// 5. Always refetch on settle to reconcile with server truth
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
<HeartIcon
liked={post.liked}
onClick={() => like.mutate({ postId: post.id })} // no isPending check needed
/>
```
**Optimistic updates for lists (add an item with a temporary ID):**
```tsx
const create = useMutation({
mutationFn: createComment,
onMutate: async (newComment) => {
await queryClient.cancelQueries({ queryKey: ['comments', postId] });
const previous = queryClient.getQueryData<Comment[]>(['comments', postId]) ?? [];
// Append optimistic comment with a tempId
const tempId = `temp-${Date.now()}`;
queryClient.setQueryData<Comment[]>(['comments', postId], [
...previous,
{ ...newComment, id: tempId, pending: true },
]);
return { previous, tempId };
},
onError: (_e, _vars, ctx) => {
if (ctx) queryClient.setQueryData(['comments', postId], ctx.previous);
},
onSuccess: (serverComment, _vars, ctx) => {
// Replace the temp comment with the server version (real ID)
queryClient.setQueryData<Comment[]>(['comments', postId], comments =>
comments?.map(c => c.id === ctx?.tempId ? serverComment : c) ?? [serverComment]
);
},
});
```
**When NOT to optimize optimistically:**
- Mutations whose server-side outcome is unpredictable (e.g., "reserve seat" — the seat may already be taken)
- Mutations that compute new data unknowable client-side (e.g., "generate AI summary")
- Critical-path mutations where wrong-then-rollback is worse than waiting (charge processing, security-sensitive)
**Race condition watch:** if two mutations on the same entity race, the second `onMutate` snapshot may already reflect the first's optimistic update. Always cancel queries in `onMutate` to flush any in-flight reads that might land between snapshot and revert.
Reference: [TanStack Query — Optimistic Updates](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates)
references/mutate-set-data-over-invalidate.md
---
title: Use setQueryData over Invalidate When the Result is Known
impact: MEDIUM
impactDescription: eliminates 1 refetch per mutation
tags: mutate, set-query-data, invalidate, network
---
## Use setQueryData over Invalidate When the Result is Known
Invalidating a query schedules a refetch — a network round-trip the user pays for. But when the mutation response already contains the updated entity (most REST and GraphQL responses do), there's no need to refetch: write the response directly into the cache with `setQueryData`. Zero extra requests, immediate UI update.
This is the canonical pattern for "POST returns the created object" and "PUT returns the updated object."
**Incorrect (mutation returns the updated entity, but we throw it away and refetch):**
```tsx
const updateProduct = useMutation({
mutationFn: (input: ProductUpdate) => api.updateProduct(input), // returns updated Product
onSuccess: (_response, input) => {
// We had the new data right there but ignore it — fire another request
queryClient.invalidateQueries({ queryKey: ['product', input.id] });
},
});
```
**Correct (write the response directly):**
```tsx
const updateProduct = useMutation({
mutationFn: (input: ProductUpdate) => api.updateProduct(input),
onSuccess: (updated, input) => {
// Write the server's response directly into the cache — no refetch needed
queryClient.setQueryData(productKeys.detail(input.id), updated);
// Also surgically update any list views that include this product
queryClient.setQueriesData<Product[]>(
{ queryKey: productKeys.lists() },
(old) => old?.map(p => p.id === updated.id ? updated : p)
);
},
});
```
**For "create" mutations (append to a list):**
```tsx
const createComment = useMutation({
mutationFn: (input: CommentInput) => api.createComment(input),
onSuccess: (created, input) => {
queryClient.setQueryData<Comment[]>(
['comments', input.postId],
(old) => old ? [...old, created] : [created]
);
},
});
```
**For "delete" mutations (remove from cache, drop from lists):**
```tsx
const deleteProduct = useMutation({
mutationFn: ({ id }: { id: string }) => api.deleteProduct(id),
onSuccess: (_response, { id }) => {
queryClient.removeQueries({ queryKey: productKeys.detail(id) });
queryClient.setQueriesData<Product[]>(
{ queryKey: productKeys.lists() },
(old) => old?.filter(p => p.id !== id)
);
},
});
```
**When invalidation IS the right choice:**
- The mutation has effects beyond the entity itself (e.g., updating a product changes "related products" — refetch related)
- The server applied business logic the client can't predict (computed fields, denormalized counts, audit fields)
- Multiple queries share denormalized state hard to update consistently in code
- You don't trust the response shape (third-party API returns partial responses)
**Pattern: setQueryData first, then invalidate as a safety net:**
```tsx
const updateProduct = useMutation({
mutationFn: (input: ProductUpdate) => api.updateProduct(input),
onSuccess: (updated, input) => {
// Immediate cache update for instant UX
queryClient.setQueryData(productKeys.detail(input.id), updated);
// After a brief delay, refetch to catch denormalized fields we couldn't predict
setTimeout(
() => queryClient.invalidateQueries({ queryKey: productKeys.detail(input.id) }),
1000
);
},
});
```
**Pair with [[mutate-optimistic-updates-with-rollback]]:** the optimistic update writes immediately; `setQueryData` in `onSuccess` reconciles with server response; `onError` rolls back. End-to-end: no network round-trip is ever in the critical path.
Reference: [TanStack Query — Updates from Mutation Responses](https://tanstack.com/query/latest/docs/framework/react/guides/updates-from-mutation-responses)
references/mutate-surgical-invalidation.md
---
title: Invalidate Surgically, Not Globally
impact: MEDIUM
impactDescription: reduces post-mutation refetch storms 10-100×
tags: mutate, invalidation, surgical, query-keys
---
## Invalidate Surgically, Not Globally
After a mutation, the easy path is `queryClient.invalidateQueries()` with no arguments — every cached query refetches. On a complex page with 50 active queries, that's a 50-request storm for what was typically a single-entity change. Be surgical: invalidate only the queries that the mutation actually affects, using the key factory pattern ([[cache-shared-key-factory]]) to target precisely.
The trade-off: precise invalidation requires understanding which queries depend on which entities. The key factory pattern makes this explicit.
**Incorrect (nuke everything — 50-request storm after a single update):**
```tsx
const updateProductName = useMutation({
mutationFn: ({ id, name }) => api.updateProduct(id, { name }),
onSuccess: () => {
queryClient.invalidateQueries(); // refetches EVERY query: cart, user, settings, search...
},
});
```
**Correct (target only what changed):**
```tsx
const updateProductName = useMutation({
mutationFn: ({ id, name }) => api.updateProduct(id, { name }),
onSuccess: (_, { id }) => {
// Refetch the product detail; refetch any list that includes this product
queryClient.invalidateQueries({ queryKey: productKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
// That's it — user, settings, cart are untouched
},
});
```
**Hierarchical invalidation with the key factory:**
```ts
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters: Filters) => [...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};
// Invalidate at the right level for the mutation's blast radius
queryClient.invalidateQueries({ queryKey: productKeys.detail(id) }); // one product
queryClient.invalidateQueries({ queryKey: productKeys.lists() }); // all list views
queryClient.invalidateQueries({ queryKey: productKeys.all }); // everything product-related
```
**Match exactly vs prefix matching:**
```ts
// Default: prefix match — invalidates ['products', 'list', { filter: 'A' }] AND ['products', 'list', { filter: 'B' }]
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
// Exact match: only the precise key
queryClient.invalidateQueries({ queryKey: productKeys.list({ filter: 'A' }), exact: true });
// Predicate: full control
queryClient.invalidateQueries({
predicate: (query) => {
// Invalidate only list queries with a filter that included the changed product's category
return query.queryKey[0] === 'products' &&
query.queryKey[1] === 'list' &&
(query.queryKey[2] as any)?.category === changedCategory;
},
});
```
**Refetch only "active" queries (those currently mounted):**
```ts
queryClient.invalidateQueries({
queryKey: productKeys.lists(),
refetchType: 'active', // don't refetch background queries — they'll refetch when next mounted
});
```
**Common invalidation patterns by mutation type:**
| Mutation | Invalidate |
|----------|-----------|
| Update entity field | `detail(id)` + `lists()` |
| Create entity | `lists()` only (and possibly `setQueryData` to add to a known list) |
| Delete entity | `removeQueries(detail(id))` + `lists()` |
| Bulk operation | Match by predicate — affected entities only |
**When to invalidate everything:** logout (`queryClient.clear()`) or major context switch (user changed accounts). For normal mutations, surgical wins.
Reference: [TanStack Query — Query Invalidation](https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation)
references/orch-avoid-effect-chains.md
---
title: Avoid useEffect Fetch Chains
impact: CRITICAL
impactDescription: prevents render-fetch-render-fetch waterfalls
tags: orch, useeffect, waterfalls, dependencies
---
## Avoid useEffect Fetch Chains
A common anti-pattern: `useEffect` fetches A; once A arrives, a *second* `useEffect` watches A and fetches B; a third watches B and fetches C. Each fetch waits for the previous render commit, so C only starts ~600ms after page entry even though all three could have run concurrently with the right key construction.
If you can compute B's key from props (not from A's response), B doesn't depend on A — start them together. Effect chains are only correct when there is a genuine data dependency.
**Incorrect (three-stage waterfall through effect chain):**
```tsx
function UserFeed({ userId }: { userId: string }) {
const [user, setUser] = useState<User>();
const [posts, setPosts] = useState<Post[]>();
const [comments, setComments] = useState<Comment[]>();
useEffect(() => { fetchUser(userId).then(setUser); }, [userId]);
useEffect(() => { if (user) fetchPosts(user.id).then(setPosts); }, [user]); // waits for user
useEffect(() => { if (posts) fetchCommentsFor(posts.map(p => p.id)).then(setComments); }, [posts]); // waits for posts
// user.id === userId — fetchPosts didn't actually need user, it needed userId!
// Three sequential round-trips for what should be two.
}
```
**Correct (flatten the dependency graph — only true dependencies wait):**
```tsx
function UserFeed({ userId }: { userId: string }) {
// user and posts both keyed on userId — they're independent
const user = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
const posts = useQuery({ queryKey: ['posts', userId], queryFn: () => fetchPosts(userId) });
// comments genuinely depends on posts — keep that sequential
const comments = useQuery({
queryKey: ['comments', posts.data?.map(p => p.id)],
queryFn: () => fetchCommentsFor(posts.data!.map(p => p.id)),
enabled: !!posts.data,
});
}
```
**The test for "is this really a dependency":** can the key for the next fetch be computed from props alone? If yes, it's not a dependency — flatten it.
Reference: [React — You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
references/orch-batch-n-plus-one-fanout.md
---
title: Batch N+1 Fan-Out with DataLoader Pattern
impact: CRITICAL
impactDescription: reduces N requests to 1
tags: orch, batching, dataloader, n-plus-one, fanout
---
## Batch N+1 Fan-Out with DataLoader Pattern
When a list renders N cards and each card independently fetches `/users/:id`, you've created an N+1 storm — one request per card, each ~300ms, all hitting the backend in the same animation frame. Batch them: collect IDs requested within a microtask tick and fire one `/users?ids=...` request. The DataLoader pattern (from Facebook's Relay infrastructure) is the standard implementation.
**Incorrect (N+1 fan-out, every card fetches independently):**
```tsx
function CommentList({ comments }: { comments: Comment[] }) {
return comments.map(c => <CommentRow key={c.id} comment={c} />);
}
function CommentRow({ comment }: { comment: Comment }) {
// Each row fires GET /users/:id — 200 comments = 200 requests
const { data: author } = useQuery({
queryKey: ['user', comment.authorId],
queryFn: () => fetchUser(comment.authorId),
});
return <div>{author?.name}: {comment.text}</div>;
}
```
**Correct (DataLoader batches per-tick into one bulk call):**
```tsx
import DataLoader from 'dataloader';
const userLoader = new DataLoader<string, User>(async (ids) => {
// All IDs requested in this tick arrive together
const users = await fetchUsersBulk([...ids]); // GET /users?ids=a,b,c
const byId = new Map(users.map(u => [u.id, u]));
return ids.map(id => byId.get(id) ?? new Error(`user ${id} not found`));
});
function CommentRow({ comment }: { comment: Comment }) {
const { data: author } = useQuery({
queryKey: ['user', comment.authorId],
queryFn: () => userLoader.load(comment.authorId), // batched
});
return <div>{author?.name}: {comment.text}</div>;
}
```
**Alternative (normalized cache fed by a single bulk fetch):**
Fetch `users` once at the parent and read by ID from a normalized cache (see [[cache-normalize-shared-entities]]).
**Benefits:**
- 200 requests → 1 request
- Backend can index-scan once instead of paginate-by-key 200 times
- Same wall-clock latency as a single call
Reference: [graphql/dataloader](https://github.com/graphql/dataloader)
references/orch-dedupe-in-flight-requests.md
---
title: Deduplicate In-Flight Requests by Key
impact: CRITICAL
impactDescription: reduces M concurrent calls to 1
tags: orch, deduplication, in-flight, swr, request-collapsing
---
## Deduplicate In-Flight Requests by Key
When three components mount on the same render and all need `/me`, a naive setup fires three identical `/me` requests in parallel. The backend does three lookups; the client wastes three round-trips' worth of bandwidth. In-flight deduplication — keying ongoing promises by their request signature — collapses these to one shared promise that all three components await.
SWR and TanStack Query do this automatically when the same `queryKey` is used in multiple components. Raw `fetch` does not — you have to wire it.
**Incorrect (three components, three identical /me requests):**
```tsx
// Without dedup, each component independently calls fetch
function Header() { const me = useFetch('/me'); return <Avatar src={me?.avatar} />; }
function Sidebar() { const me = useFetch('/me'); return <span>{me?.name}</span>; }
function Footer() { const me = useFetch('/me'); return <small>© {me?.org}</small>; }
// Network tab: GET /me, GET /me, GET /me — all in flight at once
```
**Correct (SWR/Query dedupe by key):**
```tsx
// Same queryKey across components → one in-flight request, three subscribers
function Header() { const { data } = useQuery({ queryKey: ['me'], queryFn: fetchMe }); /*...*/ }
function Sidebar() { const { data } = useQuery({ queryKey: ['me'], queryFn: fetchMe }); /*...*/ }
function Footer() { const { data } = useQuery({ queryKey: ['me'], queryFn: fetchMe }); /*...*/ }
// Network tab: GET /me — once
```
**Implementation (raw fetch with promise cache):**
```ts
const inflight = new Map<string, Promise<Response>>();
export function dedupedFetch(url: string, init?: RequestInit): Promise<Response> {
const key = `${init?.method ?? 'GET'} ${url}`;
let p = inflight.get(key);
if (!p) {
p = fetch(url, init).finally(() => inflight.delete(key));
inflight.set(key, p);
}
return p.then(r => r.clone()); // clone so each caller can read the body
}
```
**Warning (dedup applies to GETs only):** Deduplicating POST/PUT/DELETE silently drops user actions. Key only safe-method calls.
Reference: [SWR — Automatic Deduplication](https://swr.vercel.app/docs/advanced/performance#deduplication)
references/orch-lift-fetch-to-route-loader.md
---
title: Lift Fetches into Route Loaders
impact: CRITICAL
impactDescription: 200-800ms saved on route entry
tags: orch, route-loader, prefetch, code-split, tanstack-router
---
## Lift Fetches into Route Loaders
When a component fetches inside `useEffect`, the browser must: download the route's JS chunk → parse → render the component → mount → run effect → start fetch. Each step is sequential. Route loaders flip this: the fetch starts in parallel with the chunk download. By the time the component is ready to render, the data is already there (or close to it).
This is supported natively in TanStack Router, Remix, React Router 6.4+, and Next.js (RSC). Use it for data the route always needs.
**Incorrect (fetch happens after render — sequential waterfall):**
```tsx
// /routes/product.$id.tsx
function ProductPage({ id }: { id: string }) {
const { data, isLoading } = useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
});
if (isLoading) return <Skeleton />;
return <Product product={data!} />;
}
// Timeline: nav click → download chunk (200ms) → render → effect → fetch (300ms) → done
// Total: ~500ms
```
**Correct (loader fires fetch in parallel with chunk download):**
```tsx
// /routes/product.$id.tsx — TanStack Router
export const Route = createFileRoute('/product/$id')({
loader: ({ params, context: { queryClient } }) =>
queryClient.ensureQueryData({
queryKey: ['product', params.id],
queryFn: () => fetchProduct(params.id),
}),
component: ProductPage,
});
function ProductPage() {
const { id } = Route.useParams();
// Data is already cached by the loader — this resolves synchronously
const { data } = useSuspenseQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
});
return <Product product={data} />;
}
// Timeline: nav click → download chunk + fetch in parallel (max 300ms) → render → done
// Total: ~300ms
```
**Alternative (Next.js Server Component):**
```tsx
// app/product/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetchProduct(params.id); // server-side, no client roundtrip
return <Product product={product} />;
}
```
**When NOT to use loaders:** for data that's user-interaction-driven (e.g. a modal's content). Keep loaders for route-level data the user committed to seeing by navigating.
Reference: [TanStack Router — Data Loading](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading)
references/orch-parallelize-independent-fetches.md
---
title: Parallelize Independent Fetches
impact: CRITICAL
impactDescription: eliminates N-1 sequential round-trips
tags: orch, parallel, promise-all, waterfalls, network
---
## Parallelize Independent Fetches
Two `await` statements in a row are serialized — the second request can't start until the first response arrives. If neither depends on the other, you've paid for two round-trips when you could pay for one. In a page that loads user, settings, and notifications this turns a 300ms page into a 900ms page for no reason.
**Incorrect (sequential awaits, three round-trips):**
```tsx
async function loadDashboard(userId: string) {
const user = await fetchUser(userId); // wait 300ms
const settings = await fetchSettings(userId); // wait another 300ms (independent of user)
const notifications = await fetchNotifications(userId); // wait another 300ms
return { user, settings, notifications }; // total: ~900ms
}
```
**Correct (parallel, one round-trip time):**
```tsx
async function loadDashboard(userId: string) {
const [user, settings, notifications] = await Promise.all([
fetchUser(userId),
fetchSettings(userId),
fetchNotifications(userId),
]); // total: ~300ms — bound by the slowest call
return { user, settings, notifications };
}
```
**With TanStack Query (parallel queries declare independence):**
```tsx
function Dashboard({ userId }: { userId: string }) {
const user = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
const settings = useQuery({ queryKey: ['settings', userId], queryFn: () => fetchSettings(userId) });
const notifs = useQuery({ queryKey: ['notifs', userId], queryFn: () => fetchNotifications(userId) });
// All three fire on mount in parallel — no orchestration code needed
}
```
**When NOT to parallelize:** when the second request genuinely depends on the first's result (e.g. fetching items by IDs returned from a search). Use `Promise.all` only on independent calls.
Reference: [MDN — Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all)
references/orch-prefer-bulk-endpoint-for-fanout.md
---
title: Prefer a Bulk Endpoint over N Parallel Endpoints
impact: CRITICAL
impactDescription: reduces N round-trips to 1
tags: orch, bulk, fanout, api-design, batching
---
## Prefer a Bulk Endpoint over N Parallel Endpoints
`Promise.all([fetchUser(a), fetchUser(b), fetchUser(c)])` is parallel — but it's still three TCP/TLS round-trips, three HTTP header sets, three log lines, three rate-limit decrements. When the backend supports `GET /users?ids=a,b,c`, one round-trip replaces all three. Bulk endpoints are also easier for the backend to plan as a single query.
If the bulk endpoint doesn't exist yet, ask for it before scaling parallel fan-out past ~10 items. Backends almost always prefer one indexed lookup of 200 IDs to 200 indexed lookups of 1 ID each.
**Incorrect (200 parallel requests for a list view):**
```tsx
function FavoritesPage({ favoriteIds }: { favoriteIds: string[] }) {
// Parallel — but 200 HTTP requests, 200 rows in access log, 200 SSL handshakes
const queries = useQueries({
queries: favoriteIds.map(id => ({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
})),
});
// ...
}
```
**Correct (one bulk fetch, then split into the cache):**
```tsx
function FavoritesPage({ favoriteIds }: { favoriteIds: string[] }) {
const { data: products } = useQuery({
queryKey: ['products', { ids: [...favoriteIds].sort() }], // sort for stable key
queryFn: () => fetchProductsBulk(favoriteIds), // one round-trip
});
// After the bulk fetch, seed per-id cache entries so single-item reads hit the cache:
const queryClient = useQueryClient();
useEffect(() => {
products?.forEach(p => queryClient.setQueryData(['product', p.id], p));
}, [products]);
}
```
**When a bulk endpoint isn't available:** combine with [[orch-batch-n-plus-one-fanout]] (DataLoader) so calls collected in a tick still merge into one bulk request to a bulk endpoint you control.
**Backend-side benefit:** one query with `WHERE id IN (...)` uses one index scan; 200 individual queries do 200 index seeks. The bulk version is often 10-50x faster on the database too.
Reference: [Vercel — Fetching Data on the Server](https://vercel.com/blog/everything-about-data-fetching-in-nextjs)
references/orch-server-fetch-when-possible.md
---
title: Move Fetches to the Server When Possible
impact: HIGH
impactDescription: eliminates client-side round-trip + JS payload
tags: orch, rsc, ssr, server-components, nextjs
---
## Move Fetches to the Server When Possible
A client-side fetch costs: JS bundle to do the fetching (kb) + a round-trip from the user's network (often a slow mobile connection) + a render shift when data arrives. A server-side fetch on the same data costs: an intra-datacenter round-trip (often <5ms). When the fetch doesn't depend on client-only state (window size, auth tokens in localStorage), do it on the server.
React Server Components, Next.js App Router, and Remix loaders all support this. Use them for the "first paint" data of any route.
**Incorrect (client fetches data the server already had):**
```tsx
'use client';
function ProductPage({ id }: { id: string }) {
const { data, isLoading } = useQuery({
queryKey: ['product', id],
queryFn: () => fetch(`/api/products/${id}`).then(r => r.json()),
});
if (isLoading) return <Skeleton />;
return <Product product={data!} />;
}
// Cost: ~30kb of fetch/query/serializer JS shipped, +1 client round-trip on a 3G phone (~600ms)
```
**Correct (server component fetches and embeds):**
```tsx
// app/products/[id]/page.tsx — Server Component (no 'use client')
import { db } from '@/lib/db';
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.products.findUnique({ where: { id: params.id } });
return <Product product={product} />; // HTML streamed directly, zero client fetching code
}
```
**When to keep it client-side:**
- Data that depends on client state (window, cookies set via JS, in-page selections)
- Real-time data (subscriptions, polling) — server is one-shot
- Mutations and their optimistic responses
**Hybrid pattern (server-fetch the trunk, client-fetch the branches):**
```tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetchProduct(params.id); // server: critical content
return (
<>
<Product product={product} />
<RelatedProducts productId={product.id} /> {/* client: lazy, optional */}
</>
);
}
```
Reference: [React — Server Components](https://react.dev/reference/rsc/server-components)
references/prefetch-budget-and-priority.md
---
title: Bound Prefetch Bandwidth by Priority Tier
impact: MEDIUM-HIGH
impactDescription: prevents prefetch from competing with critical fetches
tags: prefetch, priority, bandwidth, budget, save-data
---
## Bound Prefetch Bandwidth by Priority Tier
Prefetching the next 50 things "in case the user needs them" is great until those 50 prefetches drown out the *actual* user-initiated fetch happening at the same moment. Tier prefetches by priority: critical user-initiated fetches go first, navigational prefetches (likely-next) second, predictive prefetches (might-be-next) last. Use `fetch`'s `priority` hint, and gate aggressive prefetching on connection quality.
The `Network Information API` and `fetch` priority hints let the browser shed lower-priority work when the network is busy or slow.
**Incorrect (prefetch competes for the same slots as the user's actual fetch):**
```tsx
function ProductGrid({ products }: { products: Product[] }) {
// Prefetch detail for all 50 visible products
useEffect(() => {
products.forEach(p => {
queryClient.prefetchQuery({
queryKey: ['product', p.id],
queryFn: () => fetchProduct(p.id),
});
});
// 50 parallel prefetches drown out the user clicking "Add to Cart"
}, [products]);
}
```
**Correct (priority hints + connection-aware + bounded concurrency):**
```ts
type Priority = 'high' | 'low' | 'auto';
export async function fetchWithPriority(
url: string,
priority: Priority = 'auto'
): Promise<Response> {
// Skip low-priority prefetches on slow or save-data connections
// @ts-expect-error — Network Information API
const conn = navigator.connection;
if (priority === 'low' && (conn?.saveData || conn?.effectiveType === '2g')) {
throw new Error('skipped-prefetch-on-slow-connection');
}
return fetch(url, { priority }); // fetch() supports priority hints in Chrome/Edge
}
```
**Tier prefetches with a budget:**
```tsx
const prefetchLimit = pLimit(2); // at most 2 concurrent prefetches
function ProductCard({ product }: { product: Product }) {
useEffect(() => {
prefetchLimit(() =>
queryClient.prefetchQuery({
queryKey: ['product', product.id],
queryFn: ({ signal }) => fetchWithPriority(`/api/products/${product.id}`, 'low')
.then(r => r.json()),
staleTime: 60_000,
})
);
}, [product.id]);
}
```
**Three tiers:**
| Tier | What | Priority | Bandwidth share |
|------|------|----------|-----------------|
| Critical | User clicked / navigated | `high` | Unbounded |
| Likely-next | Hover, viewport sentinel | `auto` | 4-6 concurrent |
| Predictive | Idle-time guesses | `low` | 1-2 concurrent, gated by `connection` |
**Respect `Save-Data`:** when the user has enabled data-saver mode, the browser sends `Save-Data: on` and `navigator.connection.saveData === true`. Skip predictive prefetch entirely; show smaller images; fetch only on direct interaction.
**Cancellation interplay:** when the user clicks something that needs an in-flight prefetch slot, cancel the lowest-priority outstanding prefetches first. AbortController + a priority-tagged queue makes this clean.
Reference: [WICG — Priority Hints](https://wicg.github.io/priority-hints/) | [MDN — Network Information API](https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API)
references/prefetch-hover-intent-links.md
---
title: Prefetch Links on Hover and Intent
impact: HIGH
impactDescription: 100-300ms faster perceived navigation
tags: prefetch, hover, intent, links, navigation
---
## Prefetch Links on Hover and Intent
Users take 100-300ms between hovering a link and clicking it. That's enough time to fire the next page's data and chunk fetch — by the time the click registers, the data is already in cache and the navigation is instant. Even better: prefetch on `pointerdown` (intent signal that beats `click` by ~80ms).
Next.js `<Link>` and TanStack Router do this automatically. For custom navigation, wire it explicitly with `onMouseEnter` / `onPointerDown`.
**Incorrect (link click → wait for chunk + data):**
```tsx
function ProductLink({ id, name }: { id: string; name: string }) {
return <a href={`/product/${id}`}>{name}</a>;
// Click → chunk download (200ms) → data fetch (300ms) → render → 500ms perceived wait
}
```
**Correct (prefetch on hover/intent):**
```tsx
function ProductLink({ id, name }: { id: string; name: string }) {
const queryClient = useQueryClient();
const prefetch = () =>
queryClient.prefetchQuery({
queryKey: productKeys.detail(id),
queryFn: () => fetchProduct(id),
staleTime: 30_000,
});
return (
<a
href={`/product/${id}`}
onMouseEnter={prefetch}
onPointerDown={prefetch}
>
{name}
</a>
);
// Hover ~150ms before click → fetch already in flight when click registers
}
```
**With Next.js Link (automatic):**
```tsx
import Link from 'next/link';
<Link href={`/product/${id}`} prefetch={true}>{name}</Link>
// Prefetches route + data in viewport (auto) or on hover (with prefetch={true})
```
**With TanStack Router (automatic):**
```tsx
import { Link } from '@tanstack/react-router';
<Link to="/product/$id" params={{ id }} preload="intent">{name}</Link>
// preload: "intent" prefetches on hover/focus
```
**When NOT to prefetch:**
- Mobile (no hover) — prefetch on visible-in-viewport via IntersectionObserver instead
- Low-confidence destinations (e.g. a tag cloud where users rarely click each tag) — prefetching every one wastes bandwidth
- Authenticated endpoints when the user might not be authorized — wasted server work
**Mind the bandwidth budget:** prefetching N nearby links of M kb each means N×M kb downloaded even if the user clicks none. Limit to high-confidence (current viewport, primary CTAs).
Reference: [Next.js — Link Prefetching](https://nextjs.org/docs/app/api-reference/components/link#prefetch) | [TanStack Router — Preloading](https://tanstack.com/router/latest/docs/framework/react/guide/preloading)
references/prefetch-hydrate-server-cache.md
---
title: Hydrate the Client Cache from Server-Rendered Data
impact: HIGH
impactDescription: eliminates first-fetch on cached entities
tags: prefetch, hydration, ssr, rsc, dehydrate
---
## Hydrate the Client Cache from Server-Rendered Data
When the server already fetched a product to render its HTML, the client shouldn't fetch it again on mount. Hydration ships the server's cache state inside the HTML; the client picks it up before the first render so `useQuery` sees fresh data without a network call. Without this, every server-rendered page does a "rehydration refetch" — the most common cause of duplicate work in SSR React apps.
Both TanStack Query (`HydrationBoundary`) and SWR (`fallback` prop) support this pattern. Next.js App Router with RSC builds this in: server-fetched data is embedded in the streamed RSC payload.
**Incorrect (server fetches, client refetches the same thing):**
```tsx
// pages/product/[id].tsx (Pages Router)
export async function getServerSideProps({ params }) {
const product = await fetchProduct(params.id);
return { props: { product } }; // ships data as plain prop
}
export default function ProductPage({ product }) {
// Now the client mounts and...
const { data } = useQuery({
queryKey: ['product', product.id],
queryFn: () => fetchProduct(product.id), // ← refetches what the server already did
});
return <Product product={data ?? product} />;
}
```
**Correct (dehydrate on the server, hydrate on the client):**
```tsx
// pages/product/[id].tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export async function getServerSideProps({ params }) {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: productKeys.detail(params.id),
queryFn: () => fetchProduct(params.id),
});
return { props: { dehydratedState: dehydrate(queryClient) } };
}
export default function ProductPage({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<ProductView />
</HydrationBoundary>
);
}
function ProductView() {
const { id } = useParams();
const { data } = useQuery({
queryKey: productKeys.detail(id),
queryFn: () => fetchProduct(id),
// Reads server-prefetched data — no client refetch within staleTime
staleTime: 60_000,
});
return <Product product={data!} />;
}
```
**With Next.js App Router + RSC (zero-config):**
```tsx
// app/product/[id]/page.tsx — Server Component
import { HydrationBoundary, dehydrate, QueryClient } from '@tanstack/react-query';
export default async function ProductPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: productKeys.detail(params.id),
queryFn: () => fetchProduct(params.id),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ClientProductView id={params.id} />
</HydrationBoundary>
);
}
```
**Without query libraries (SWR fallback):**
```tsx
<SWRConfig value={{ fallback: { [`/api/products/${id}`]: product } }}>
<ProductView />
</SWRConfig>
```
**Critical detail (`staleTime` matters during hydration):** if `staleTime` is 0, the client will refetch on hydration anyway because it considers the server data "instantly stale." Set `staleTime` to at least the time it takes the user to start interacting (~1s minimum, typically 30-60s).
Reference: [TanStack Query — SSR & Hydration](https://tanstack.com/query/latest/docs/framework/react/guides/ssr)
references/prefetch-idle-likely-next.md
---
title: Prefetch Likely-Next Data on Idle
impact: MEDIUM-HIGH
impactDescription: maintains instant transitions for predictable paths
tags: prefetch, idle, requestIdleCallback, predictive
---
## Prefetch Likely-Next Data on Idle
Some user paths are highly predictable: after viewing a product, ~40% open the reviews tab; after opening a checkout, nearly 100% will see the shipping step. Prefetching this data during the browser's idle time (`requestIdleCallback`) costs nothing — main-thread work runs first, and the prefetch fills the gap when the browser would otherwise be idle.
The win is not just speed but consistency: even when prefetch *didn't* race to completion, you've shifted hundreds of milliseconds of work out of the user's click → render path.
**Incorrect (only fetch on demand — every step waits):**
```tsx
function ProductPage({ id }: { id: string }) {
return (
<Tabs>
<Tab name="Details"><ProductDetails id={id} /></Tab>
<Tab name="Reviews"><Reviews id={id} /></Tab> {/* fetches on activation */}
<Tab name="Q&A"><QA id={id} /></Tab> {/* fetches on activation */}
</Tabs>
);
// User clicks Reviews → 300ms wait every single time
}
```
**Correct (prefetch likely-next during idle):**
```tsx
function ProductPage({ id }: { id: string }) {
const queryClient = useQueryClient();
useEffect(() => {
// Reviews are opened ~40% of the time — worth prefetching at idle
const handle = requestIdleCallback(() => {
queryClient.prefetchQuery({
queryKey: ['reviews', id],
queryFn: () => fetchReviews(id),
staleTime: 60_000,
});
});
return () => cancelIdleCallback(handle);
}, [id]);
return <Tabs>{/* ... */}</Tabs>;
}
```
**Polyfill `requestIdleCallback` (Safari only added it in 17.4):**
```ts
type IdleCb = (deadline: IdleDeadline) => void;
const ric: (cb: IdleCb) => number =
typeof window.requestIdleCallback === 'function'
? window.requestIdleCallback
: (cb) => window.setTimeout(() => cb({
didTimeout: false,
timeRemaining: () => 50,
} as IdleDeadline), 1) as unknown as number;
```
**For multi-step flows (prefetch the next step):**
```tsx
function CheckoutCart() {
const queryClient = useQueryClient();
// User landed on cart. Next step is nearly always shipping → prefetch.
useEffect(() => {
requestIdleCallback(() => {
queryClient.prefetchQuery({
queryKey: ['shipping-options'],
queryFn: fetchShippingOptions,
});
queryClient.prefetchQuery({
queryKey: ['saved-addresses'],
queryFn: fetchSavedAddresses,
});
});
}, []);
}
```
**When NOT to idle-prefetch:**
- Data that's expensive on the backend (a recommendation engine that takes 800ms of compute per call) — burning a hot backend query for users that never click is bad ROI
- Authenticated paths the user might not have permission for — wasted server work and surprising 401s
- Mobile connections — even idle prefetch costs bandwidth; gate on `navigator.connection.effectiveType !== '2g'`
Reference: [MDN — requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)
references/prefetch-parallel-loader-queries.md
---
title: Run Route Loader Queries in Parallel
impact: HIGH
impactDescription: reduces N sequential awaits to 1 round-trip
tags: prefetch, route-loader, parallel, tanstack-router, remix
---
## Run Route Loader Queries in Parallel
A loader that `await`s the user, then `await`s settings, then `await`s notifications is just a server-side waterfall — three round-trips, same as the client version. Loaders unlock real parallelism: fire all independent queries together and `await Promise.all` the dependencies. The loader is *the* place to do this because the route framework guarantees the loader completes before the component renders.
For dependent data (you need step A's result to compute step B's key), use `defer` to stream partial data without blocking the full render.
**Incorrect (sequential awaits inside the loader):**
```tsx
// TanStack Router
export const Route = createFileRoute('/dashboard')({
loader: async ({ context: { queryClient } }) => {
const user = await queryClient.ensureQueryData({
queryKey: ['user'], queryFn: fetchUser
});
const settings = await queryClient.ensureQueryData({
queryKey: ['settings'], queryFn: fetchSettings // waits for user — but doesn't need it!
});
const notifs = await queryClient.ensureQueryData({
queryKey: ['notifs'], queryFn: fetchNotifications
});
return { user, settings, notifs };
},
});
// ~900ms before the route renders
```
**Correct (parallel via Promise.all):**
```tsx
export const Route = createFileRoute('/dashboard')({
loader: async ({ context: { queryClient } }) => {
const [user, settings, notifs] = await Promise.all([
queryClient.ensureQueryData({ queryKey: ['user'], queryFn: fetchUser }),
queryClient.ensureQueryData({ queryKey: ['settings'], queryFn: fetchSettings }),
queryClient.ensureQueryData({ queryKey: ['notifs'], queryFn: fetchNotifications }),
]);
return { user, settings, notifs };
},
});
// ~300ms before the route renders
```
**Defer slow queries (don't block the route on a slow non-critical fetch):**
```tsx
// Remix-style defer / TanStack Router defer
export const Route = createFileRoute('/dashboard')({
loader: async ({ context: { queryClient } }) => {
// Fast critical data — await
const user = await queryClient.ensureQueryData({ queryKey: ['user'], queryFn: fetchUser });
// Slow non-critical data — defer (stream in after first paint)
const activityPromise = queryClient.prefetchQuery({
queryKey: ['activity'], queryFn: fetchActivity, // takes 2s
});
return { user, activityPromise }; // route renders with user; activity streams in
},
});
```
**Pattern for dependent queries:**
```tsx
loader: async ({ context: { queryClient }, params }) => {
// Critical: get the project (need its members list to fetch related users)
const project = await queryClient.ensureQueryData({
queryKey: ['project', params.id], queryFn: () => fetchProject(params.id),
});
// Now parallelize the dependent fetches
const [members, tasks, activity] = await Promise.all([
queryClient.ensureQueryData({
queryKey: ['members', project.id],
queryFn: () => fetchMembers(project.members),
}),
queryClient.ensureQueryData({
queryKey: ['tasks', project.id], queryFn: () => fetchTasks(project.id),
}),
queryClient.ensureQueryData({
queryKey: ['activity', project.id], queryFn: () => fetchActivity(project.id),
}),
]);
return { project, members, tasks, activity };
};
```
Reference: [TanStack Router — Parallel Routes](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading)
references/prefetch-viewport-triggered-next-page.md
---
title: Prefetch the Next Page Before the Sentinel Hits Viewport
impact: HIGH
impactDescription: eliminates loading-more spinners in feeds
tags: prefetch, viewport, intersection-observer, infinite-scroll, feed
---
## Prefetch the Next Page Before the Sentinel Hits Viewport
A naive infinite scroll fires "fetch next page" when the sentinel element enters the viewport — at which point the user is already at the bottom, looking at empty space, waiting for the next batch. Instead, use `IntersectionObserver` with `rootMargin` to fire the prefetch when the sentinel is *N pixels below* the viewport. By the time the user scrolls to the bottom, the next page is already in cache.
`rootMargin: '500px'` to `'1000px'` is a good default for feeds — fires a viewport before the user gets there.
**Incorrect (sentinel at viewport bottom — user sees the spinner):**
```tsx
function Feed() {
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({/* ... */});
const sentinel = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!sentinel.current || !hasNextPage) return;
const obs = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) fetchNextPage();
}); // no rootMargin → triggers at the moment of intersection
obs.observe(sentinel.current);
return () => obs.disconnect();
}, [hasNextPage]);
return <>{/* items */}<div ref={sentinel} /></>;
// User scrolls to bottom → sees loading spinner for ~300ms
}
```
**Correct (rootMargin offsets the trigger upward):**
```tsx
function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeedPage(pageParam),
initialPageParam: null,
getNextPageParam: last => last.nextCursor,
});
const sentinel = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sentinel.current;
if (!el || !hasNextPage) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !isFetchingNextPage) fetchNextPage();
},
{ rootMargin: '800px' } // fires when sentinel is 800px below viewport
);
obs.observe(el);
return () => obs.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<>
{data?.pages.flatMap(p => p.items).map(item => <FeedItem key={item.id} {...item} />)}
<div ref={sentinel} />
{/* By the time the user scrolls here, the next page is rendered above */}
</>
);
}
```
**Tuning rootMargin:**
- Fast scrollers (mouse wheel, trackpad): 1000-2000px
- Touch devices: 500-800px
- Slow scrollers (e-readers, articles): 300-500px
Too aggressive (5000px) → prefetches pages the user never scrolls to. Too conservative (0px) → user sees the spinner.
**Guard against runaway prefetch:**
```tsx
// Stop prefetching when the user has loaded N pages without engaging (clicking an item)
if (data && data.pages.length >= 10 && !lastInteractionTime) {
return null; // don't render the sentinel; require user to tap "load more" manually
}
```
**Why IntersectionObserver beats scroll math:** the browser does the geometry on the compositor thread; your JS doesn't run on every scroll frame. Lower CPU, smoother scroll.
Reference: [MDN — IntersectionObserver rootMargin](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin)
references/protect-circuit-breaker.md
---
title: Use Circuit Breakers on Persistently Failing Endpoints
impact: HIGH
impactDescription: prevents retry storms on persistent failure
tags: protect, circuit-breaker, resilience, backoff
---
## Use Circuit Breakers on Persistently Failing Endpoints
Retries with backoff handle transient failure. But if an endpoint is *persistently* down — wrong URL, deprecated API, dependency outage — every retry is wasted load. A circuit breaker tracks failure rate per endpoint and short-circuits subsequent calls for a cooldown window once failures cross a threshold. The user sees an immediate fallback; the backend gets a chance to recover.
Three states: **closed** (calls pass through), **open** (calls reject instantly with a stale-cache fallback), **half-open** (one trial call after the cooldown; if it succeeds, close).
**Incorrect (every component independently retries a failing endpoint):**
```tsx
// 50 components on the screen, each retrying /recommendations 3x with backoff
// → 150 wasted requests to a known-failing endpoint over 30 seconds
function Carousel({ kind }: { kind: string }) {
const { data, error } = useQuery({
queryKey: ['recs', kind],
queryFn: () => fetchRecommendations(kind),
retry: 3,
});
}
```
**Correct (shared circuit breaker per endpoint):**
```ts
type CircuitState = 'closed' | 'open' | 'half-open';
class CircuitBreaker {
private state: CircuitState = 'closed';
private failures = 0;
private openedAt = 0;
constructor(
private threshold: number, // failures to open the circuit
private cooldownMs: number, // open → half-open after this
) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.openedAt < this.cooldownMs) {
throw new Error('circuit-open');
}
this.state = 'half-open'; // allow one trial
}
try {
const result = await fn();
this.failures = 0;
this.state = 'closed';
return result;
} catch (e) {
this.failures++;
if (this.state === 'half-open' || this.failures >= this.threshold) {
this.state = 'open';
this.openedAt = Date.now();
}
throw e;
}
}
}
// One breaker per endpoint, shared across components
const recsBreaker = new CircuitBreaker(5, 30_000);
useQuery({
queryKey: ['recs', kind],
queryFn: () => recsBreaker.run(() => fetchRecommendations(kind)),
retry: (attempt, e) => attempt < 2 && (e as Error).message !== 'circuit-open',
});
```
**Pair with stale-cache fallback ([[resilience-stale-fallback]])**: when the breaker is open, render the last-known-good cached result rather than an error — most users won't notice.
**Tuning:**
- Threshold: 3-10 failures (lower for critical paths, higher for noisy endpoints)
- Cooldown: 10-60s (long enough for backend recovery, short enough to retry promptly)
Reference: [Hystrix — Circuit Breaker pattern](https://github.com/Netflix/Hystrix/wiki/How-it-Works#circuit-breaker)
references/protect-collapse-identical-requests.md
---
title: Collapse Identical Requests at the Fetch Layer
impact: HIGH
impactDescription: prevents auth-refresh storms and retry-original races
tags: protect, request-collapsing, auth-refresh, retry, imperative
---
## Collapse Identical Requests at the Fetch Layer
[[orch-dedupe-in-flight-requests]] handles the easy case: same `queryKey` across components → one network request. But several scenarios escape query-key dedup and need *fetch-layer* collapsing — coalescing concurrent calls to the same URL even when callers don't share a cache key (or aren't using a query library at all):
- **Token refresh storm.** 20 in-flight requests get 401 simultaneously. Each one's retry interceptor fires `/auth/refresh`. Without fetch-layer collapsing, the auth server sees 20 refresh calls and (often) rotates the refresh token 20 times, invalidating 19.
- **Retry-during-original.** A request appears to time out at the application layer but is still flying server-side. The retry fires before the original resolves → two identical requests in flight.
- **Cross-module duplicate URLs.** Module A uses queryKey `['user', id, { includeProfile: true }]`; module B uses `['profile', id]`. Different keys, but both resolve to `GET /users/:id/profile`. Query-layer dedup misses this.
- **Imperative code paths.** Auth setup, telemetry init, feature-flag bootstrap — code that runs outside `useQuery` and might race with itself across modules.
**Incorrect (every 401'd request fires its own /auth/refresh):**
```ts
// Axios interceptor — one per request
axios.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401) {
await axios.post('/auth/refresh'); // ❌ 20 parallel requests = 20 refresh calls
return axios.request(error.config); // retry original
}
throw error;
});
```
**Correct (collapse `/auth/refresh` by URL — one in-flight at a time):**
```ts
let refreshInFlight: Promise<void> | null = null;
async function refreshToken(): Promise<void> {
if (refreshInFlight) return refreshInFlight; // existing refresh — wait for it
refreshInFlight = axios.post('/auth/refresh').then(
() => { refreshInFlight = null; },
(e) => { refreshInFlight = null; throw e; }
);
return refreshInFlight;
}
axios.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401) {
await refreshToken(); // 20 callers, 1 refresh
return axios.request(error.config);
}
throw error;
});
```
**General-purpose fetch-layer collapser:**
```ts
const inflight = new Map<string, Promise<unknown>>();
export function collapsed<T>(signature: string, fn: () => Promise<T>): Promise<T> {
const existing = inflight.get(signature) as Promise<T> | undefined;
if (existing) return existing;
const p = fn().finally(() => inflight.delete(signature));
inflight.set(signature, p);
return p;
}
// Imperative code path that might run from many modules
export function getFeatureFlags() {
return collapsed('GET /api/flags', async () => {
const res = await fetch('/api/flags');
return res.json();
});
}
```
**Why this is distinct from `orch-dedupe-in-flight-requests`:**
| Layer | What's collapsed by | Catches |
|-------|---------------------|---------|
| Query-layer ([[orch-dedupe-in-flight-requests]]) | `queryKey` match | Multiple components calling same `useQuery` |
| Fetch-layer (this rule) | `method + URL + body` match | Different keys → same URL; imperative paths; interceptors |
Query-layer dedup is the right default; fetch-layer is the safety net for cross-cutting cases. They're complementary — both should exist in apps with auth interceptors or many imperative call sites.
**Warning (mutation safety):** never collapse non-idempotent methods. `POST /charge` shared across two callers means one charge for two intents — a user-visible bug. Restrict the collapser to GET/HEAD by default. For POSTs, prefer the idempotency-key pattern ([[resilience-no-auto-retry-mutations]]).
**Bounded micro-cache for "just-completed" calls:** after the response, hold it for 100-500ms so callers that arrive moments after completion still hit the dedup before considering a network call. Avoids the race where two callers narrowly miss each other.
Reference: [SWR — Dedupe](https://swr.vercel.app/docs/api#options) | [Axios — Token Refresh Pattern](https://axios-http.com/docs/interceptors)
references/protect-concurrency-limit-fanout.md
---
title: Cap Concurrency on Client-Side Fan-Out
impact: CRITICAL
impactDescription: prevents browser connection exhaustion + backend overload
tags: protect, concurrency, p-limit, fanout, semaphore
---
## Cap Concurrency on Client-Side Fan-Out
Browsers limit themselves to ~6 concurrent connections per origin (HTTP/1.1) or several hundred multiplexed streams (HTTP/2). Fire 500 parallel `fetch()` calls and they queue inside the browser anyway — but their queueing is opaque, retries pile up, and the backend sees a sudden burst when the queue drains. Explicit concurrency caps make queueing visible and bound the burst.
The pattern: use a semaphore or `p-limit`-style limiter. 6-10 concurrent requests is a safe default for backend fetches; 2-4 for expensive operations.
**Incorrect (uncapped fan-out, opaque queueing, burst stampede):**
```ts
async function loadAllProducts(ids: string[]) {
// 500 parallel fetches — browser queues them, backend sees a thundering burst
// when the connection pool drains. Mobile networks may time out half the requests.
return Promise.all(ids.map(id => fetchProduct(id)));
}
```
**Correct (bounded concurrency with p-limit):**
```ts
import pLimit from 'p-limit';
const limit = pLimit(6); // 6 concurrent fetches max
async function loadAllProducts(ids: string[]) {
return Promise.all(ids.map(id => limit(() => fetchProduct(id))));
}
// Network tab: at most 6 in-flight at any moment, smooth backend pressure
```
**Implementation (no dependency — bounded queue):**
```ts
export function createLimiter(max: number) {
let active = 0;
const queue: Array<() => void> = [];
const next = () => {
if (active >= max || queue.length === 0) return;
active++;
queue.shift()!();
};
return <T,>(fn: () => Promise<T>): Promise<T> =>
new Promise<T>((resolve, reject) => {
queue.push(() =>
fn().then(resolve, reject).finally(() => { active--; next(); })
);
next();
});
}
const limit = createLimiter(6);
await Promise.all(ids.map(id => limit(() => fetchProduct(id))));
```
**Why this beats relying on browser queueing:**
- Visible: you can log queue depth, alert when it grows
- Tunable per endpoint (slow endpoint? cap at 2)
- Composable with retry/timeout middleware
- Backend sees predictable load instead of spiky bursts
**Pair with [[orch-prefer-bulk-endpoint-for-fanout]]** when possible — a bulk endpoint is always better than even a bounded fan-out.
Reference: [p-limit](https://github.com/sindresorhus/p-limit)
references/protect-debounce-user-driven-fetches.md
---
title: Debounce User-Driven Fetches
impact: HIGH
impactDescription: 10-30× reduction in search/filter requests
tags: protect, debounce, search, typeahead, user-input
---
## Debounce User-Driven Fetches
A search input bound to a fetch fires one request per keystroke. Type "headphones" and the backend sees 10 search requests: "h", "he", "hea", "head", ..., "headphones". Only the last is the user's actual intent — the prefixes are byproducts of typing speed. Debounce: delay the fetch until the user pauses typing.
Pair debounce with request cancellation ([[resilience-abort-on-unmount]]) so that responses for stale prefixes don't overwrite the final results.
**Incorrect (fetch on every keystroke):**
```tsx
function Search() {
const [term, setTerm] = useState('');
const { data } = useQuery({
queryKey: ['search', term],
queryFn: () => searchProducts(term),
enabled: term.length >= 2,
});
return <input value={term} onChange={e => setTerm(e.target.value)} />;
}
// User types "headphones" in 1.5s → 10 backend requests, 9 wasted
```
**Correct (debounce — fetch after user pauses):**
```tsx
import { useDebounce } from 'use-debounce'; // or your own hook
function Search() {
const [term, setTerm] = useState('');
const [debouncedTerm] = useDebounce(term, 300); // wait 300ms after last keystroke
const { data } = useQuery({
queryKey: ['search', debouncedTerm],
queryFn: ({ signal }) => searchProducts(debouncedTerm, { signal }),
enabled: debouncedTerm.length >= 2,
placeholderData: keepPreviousData,
});
return <input value={term} onChange={e => setTerm(e.target.value)} />;
}
// User types "headphones" in 1.5s → 1 request (after the pause)
```
**Implementation (no dependency):**
```ts
export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(t);
}, [value, delayMs]);
return debounced;
}
```
**Tuning the delay:**
- Typeahead/autocomplete: 150-250ms (snappy)
- Filter chips, range sliders: 300-500ms
- Slider scrubbing (e.g. price range): 500-800ms (user keeps moving)
**Pair with [[resilience-abort-on-unmount]]:** debounce reduces the *count*; cancellation prevents a slow earlier response from clobbering a fast later one.
Reference: [React — Debouncing Hooks](https://react.dev/reference/react/useDeferredValue) | [use-debounce](https://github.com/xnimorz/use-debounce)
references/protect-jittered-retry-backoff.md
---
title: Add Jitter to Retry Backoff
impact: CRITICAL
impactDescription: prevents thundering-herd recovery
tags: protect, retry, jitter, backoff, thundering-herd
---
## Add Jitter to Retry Backoff
When a backend goes down at T=0 and clients retry with pure exponential backoff (1s, 2s, 4s, 8s), every client retries at exactly the same moments. The recovering backend gets hit by every client simultaneously — a thundering herd — and crashes again. Random jitter spreads retries across the backoff window, so the recovering backend sees a smooth load curve instead of repeated spikes.
This is the AWS Architecture Blog standard: **full jitter** (`delay = random(0, base * 2^attempt)`) outperforms equal jitter and decorrelated jitter in most simulations.
**Incorrect (deterministic backoff — synchronized stampede):**
```ts
async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 5): Promise<T> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { return await fn(); }
catch (e) {
if (attempt === maxAttempts - 1) throw e;
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s — synchronized
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error('unreachable');
}
```
**Correct (full jitter — spreads retries across the window):**
```ts
async function withRetry<T>(
fn: () => Promise<T>,
{ maxAttempts = 5, baseMs = 200, capMs = 30_000 } = {}
): Promise<T> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { return await fn(); }
catch (e) {
if (attempt === maxAttempts - 1) throw e;
if (!isRetryable(e)) throw e; // don't retry 4xx
const cap = Math.min(capMs, baseMs * 2 ** attempt);
const delay = Math.random() * cap; // full jitter: uniform [0, cap)
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error('unreachable');
}
function isRetryable(e: unknown): boolean {
if (!(e instanceof Response)) return true; // network error — retry
return e.status >= 500 || e.status === 429; // server error or rate-limit
}
```
**TanStack Query equivalent:**
```tsx
new QueryClient({
defaultOptions: {
queries: {
retry: 3,
retryDelay: attempt =>
Math.random() * Math.min(30_000, 1000 * 2 ** attempt), // full jitter
},
},
});
```
**What NOT to retry:**
- 4xx errors (the request is malformed; retry won't help)
- Mutations that aren't idempotent (don't double-charge)
- Aborted requests (the caller already moved on)
**Honor `Retry-After`:** if the server returns 429 with a `Retry-After: 5` header, use 5s — that's a contract.
Reference: [AWS — Exponential Backoff and Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)
references/protect-rate-limit-aware-client.md
---
title: Honor Server Rate-Limit Headers Client-Side
impact: HIGH
impactDescription: prevents 429 retry loops + ban risk
tags: protect, rate-limit, 429, retry-after, headers
---
## Honor Server Rate-Limit Headers Client-Side
When the backend returns 429 (Too Many Requests), the typical retry-with-backoff path eventually succeeds — but only after several wasted attempts. The response usually carries a `Retry-After` header (seconds or HTTP date) and `X-RateLimit-*` headers indicating remaining quota. Read them: wait exactly as long as the server asks, and pre-emptively slow down when remaining quota is low.
Some APIs (Stripe, GitHub, Shopify) ban clients that hammer 429s without honoring `Retry-After`. Honoring the header is both correct and defensive.
**Incorrect (ignore Retry-After, retry with own backoff):**
```ts
async function fetchWithRetry(url: string): Promise<Response> {
for (let i = 0; i < 5; i++) {
const res = await fetch(url);
if (res.status === 429) {
await sleep(2 ** i * 1000); // ignores Retry-After header
continue;
}
return res;
}
throw new Error('too many retries');
}
```
**Correct (read Retry-After, fall back to jittered backoff if absent):**
```ts
async function fetchWithRateLimitAwareness(url: string, init?: RequestInit): Promise<Response> {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(url, init);
if (res.status !== 429) return res;
const retryAfter = parseRetryAfter(res.headers.get('Retry-After'));
const delay = retryAfter ?? Math.random() * Math.min(30_000, 1000 * 2 ** attempt);
await sleep(delay);
}
throw new Error('rate-limited after 5 attempts');
}
function parseRetryAfter(header: string | null): number | null {
if (!header) return null;
const secs = Number(header);
if (!Number.isNaN(secs)) return secs * 1000;
const date = Date.parse(header); // HTTP-date form
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
```
**Pre-emptive slowdown (use X-RateLimit-Remaining to throttle before hitting 429):**
```ts
let nextAllowedAt = 0;
export async function rateAwareFetch(url: string, init?: RequestInit): Promise<Response> {
const wait = nextAllowedAt - Date.now();
if (wait > 0) await sleep(wait);
const res = await fetch(url, init);
const remaining = Number(res.headers.get('X-RateLimit-Remaining'));
const resetAt = Number(res.headers.get('X-RateLimit-Reset')) * 1000;
if (remaining < 10 && !Number.isNaN(resetAt)) {
// Spread remaining quota across the rest of the window
nextAllowedAt = Date.now() + (resetAt - Date.now()) / Math.max(1, remaining);
}
return res;
}
```
**Why this matters at scale:** an unrelated user with 200 favorites suddenly triggering parallel fetches can blow your team's quota across the entire app. Honoring rate-limit headers makes one user's load problem stay one user's problem.
Reference: [MDN — 429 Too Many Requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) | [IETF RFC 6585](https://datatracker.ietf.org/doc/html/rfc6585)
references/protect-throttle-scroll-triggered.md
---
title: Throttle Scroll-Triggered Fetches
impact: HIGH
impactDescription: reduces 60Hz event firing to 4-10Hz fetches
tags: protect, throttle, scroll, viewport, infinite-scroll
---
## Throttle Scroll-Triggered Fetches
Scroll events fire at ~60Hz (every 16ms). Binding a "load more when near bottom" check to every event runs the check 60x per second — and if the check involves a fetch trigger, you can fire multiple "load next page" requests before the first one resolves. Throttle: ensure the check runs at most every N milliseconds.
Better still: use `IntersectionObserver` for viewport-triggered fetches. It's natively throttled by the browser and runs on the compositor thread, not the main thread.
**Incorrect (scroll handler fetches on every event):**
```tsx
function Feed() {
const handleScroll = () => {
if (window.scrollY + window.innerHeight > document.body.scrollHeight - 500) {
loadNextPage(); // can fire 30+ times before first request completes
}
};
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
}
```
**Correct (IntersectionObserver — viewport-triggered, native throttling):**
```tsx
function Feed() {
const { fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed({ cursor: pageParam }),
initialPageParam: null,
getNextPageParam: lastPage => lastPage.nextCursor,
});
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el || !hasNextPage) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !isFetchingNextPage) fetchNextPage();
},
{ rootMargin: '500px' } // pre-fetch when sentinel is 500px from viewport
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<>
<FeedItems />
<div ref={sentinelRef} /> {/* sentinel; observer fires once when visible */}
</>
);
}
```
**If you must use scroll events (throttle utility):**
```ts
export function throttle<A extends unknown[]>(fn: (...args: A) => void, ms: number) {
let last = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: A) => {
const now = Date.now();
const remaining = ms - (now - last);
if (remaining <= 0) {
if (timer) { clearTimeout(timer); timer = null; }
last = now;
fn(...args);
} else if (!timer) {
timer = setTimeout(() => { last = Date.now(); timer = null; fn(...args); }, remaining);
}
};
}
```
**Guard against double-fires:** even with throttling, check `isFetchingNextPage` before calling — a slow backend can let two trigger windows fire before the first response arrives.
Reference: [MDN — IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
references/render-cap-fanout-in-lists.md
---
title: Cap Fan-Out of Queries Inside Lists
impact: MEDIUM
impactDescription: prevents unbounded query fan-out as lists grow
tags: render, fanout, lists, useQuery, batching
---
## Cap Fan-Out of Queries Inside Lists
A list component renders N children, each containing a `useQuery`. As N grows from 10 to 100 to 1000, the number of queries grows linearly — and even with deduplication, that's 1000 cache subscriptions, 1000 effect setups, 1000 re-render triggers. The pattern works fine for small lists but degrades silently as data scales. Worse, when each child fetches a different key (e.g., per-item details), you get the N+1 problem.
The fix combines: (1) lift shared queries to the parent, (2) batch per-item fetches via DataLoader, (3) cap rendered items via virtualization.
**Incorrect (per-item useQuery inside a list — fan-out scales with N):**
```tsx
function CommentList({ commentIds }: { commentIds: string[] }) {
return commentIds.map(id => <CommentRow key={id} commentId={id} />);
}
function CommentRow({ commentId }: { commentId: string }) {
const { data: comment } = useQuery({
queryKey: ['comment', commentId],
queryFn: () => fetchComment(commentId), // 1 fetch per row × 200 rows = 200 fetches
});
const { data: author } = useQuery({
queryKey: ['user', comment?.authorId],
queryFn: () => fetchUser(comment!.authorId),
enabled: !!comment, // chained per-row fetch — see [[orch-avoid-effect-chains]]
});
}
```
**Correct (lift shared fetches to the parent, batch per-item via DataLoader):**
```tsx
function CommentList({ commentIds }: { commentIds: string[] }) {
// Single fetch for all comments (uses bulk endpoint OR DataLoader)
const { data: comments } = useQuery({
queryKey: ['comments', commentIds.slice().sort()],
queryFn: () => fetchCommentsBulk(commentIds),
staleTime: 30_000,
});
// Single fetch for all authors (deduplicates per-author)
const authorIds = useMemo(
() => [...new Set(comments?.map(c => c.authorId) ?? [])],
[comments]
);
const { data: authors } = useQuery({
queryKey: ['users', authorIds.slice().sort()],
queryFn: () => fetchUsersBulk(authorIds),
enabled: authorIds.length > 0,
staleTime: 60_000,
});
const authorById = useMemo(
() => new Map(authors?.map(a => [a.id, a]) ?? []),
[authors]
);
return comments?.map(c => (
<CommentRow key={c.id} comment={c} author={authorById.get(c.authorId)} />
));
}
function CommentRow({ comment, author }: { comment: Comment; author?: User }) {
// No fetches — pure rendering
}
```
**When per-item useQuery is legitimate:**
- The list is short and bounded (< 20 items) — fan-out is acceptable
- Each item's data is genuinely independent (e.g., a dashboard of different widget types each with their own backend)
- A bulk endpoint genuinely doesn't exist and DataLoader batching is in place
- The list is virtualized, so only ~20 children render concurrently
**Virtualization changes the math:**
```tsx
// With virtualization, only ~25 of 2000 rows mount at a time → fan-out is bounded
const virtualizer = useVirtualizer({ count: items.length, /* ... */ });
return virtualizer.getVirtualItems().map(vRow => (
<CommentRow key={vRow.key} commentId={items[vRow.index].id} />
// 25 useQuery instances active regardless of items.length
));
// Acceptable, but still — bulk loading the visible window is even better
```
**Anti-pattern (don't useQueries with massive arrays):**
```tsx
// useQueries with 1000 entries creates 1000 subscriptions
const results = useQueries({
queries: ids.map(id => ({ queryKey: ['x', id], queryFn: () => fetchX(id) })),
});
// → Use one bulk query instead, OR DataLoader if the API only supports per-item fetch
```
Reference: [TanStack Query — useQueries](https://tanstack.com/query/latest/docs/framework/react/reference/useQueries) | [[orch-batch-n-plus-one-fanout]]
references/render-colocate-fetch-with-consumer.md
---
title: Colocate Fetches with Their Consumers
impact: MEDIUM
impactDescription: prevents prop drilling and unnecessary re-renders up the tree
tags: render, colocation, prop-drilling, cache, subscription
---
## Colocate Fetches with Their Consumers
A common refactor mistake: lift `useQuery` to the page root, drill the data down as props through 5 layers. Every prop change now re-renders 5 components even though only the leaf consumes the data. With a shared cache (TanStack Query, SWR), there's no benefit to lifting — every component can call the same `useQuery` and get the same data without a single duplicate request (see [[orch-dedupe-in-flight-requests]]). Put the fetch where the data is consumed.
Colocation also makes deletion safe: if you remove the leaf, the fetch goes with it. Lifted fetches survive even when nothing consumes them.
**Incorrect (fetch at root, prop-drilled through layers):**
```tsx
function ArticlePage({ id }: { id: string }) {
const { data: article } = useQuery({ queryKey: ['article', id], queryFn: () => fetchArticle(id) });
const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
return <ArticleLayout article={article} user={user} />;
}
function ArticleLayout({ article, user }: { article: Article; user: User }) {
return (
<>
<Header user={user} /> {/* needs user */}
<ArticleBody article={article} /> {/* needs article */}
<Sidebar user={user} article={article} />{/* drills both into 5 grandchildren */}
</>
);
}
// Every render of ArticlePage re-renders ArticleLayout AND all its children
// Even unrelated state changes at the root cascade through the whole tree
```
**Correct (colocate each fetch with its consumer):**
```tsx
function ArticlePage({ id }: { id: string }) {
return <ArticleLayout id={id} />;
}
function ArticleLayout({ id }: { id: string }) {
// Layout is pure — no fetching, no re-render on data changes
return (
<>
<Header />
<ArticleBody id={id} />
<Sidebar id={id} />
</>
);
}
function Header() {
const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
return <header>{user?.name}</header>;
}
function ArticleBody({ id }: { id: string }) {
const { data: article } = useQuery({ queryKey: ['article', id], queryFn: () => fetchArticle(id) });
return <article>{article?.body}</article>;
}
// Each component subscribes only to what it renders; updates are surgical
// A change to `user` re-renders Header alone, not ArticleBody
```
**Custom hooks make colocation idiomatic:**
```tsx
// One source of truth for "how to fetch X" — testable, reusable
function useArticle(id: string) {
return useQuery({
queryKey: ['article', id],
queryFn: () => fetchArticle(id),
staleTime: 60_000,
});
}
function ArticleBody({ id }: { id: string }) {
const { data, isLoading } = useArticle(id);
// ...
}
function ArticleMeta({ id }: { id: string }) {
const { data } = useArticle(id); // dedupes — same cache entry, no extra fetch
return <small>By {data?.authorName}</small>;
}
```
**Combine with [[render-suspense-per-section]]** for the full flow: each consumer suspends independently inside its own Suspense boundary.
**When lifting is correct:**
- Data that must be passed through to a *child library* (a context provider that needs the value)
- Conditional rendering that depends on the data (e.g., redirect if 404) — the conditional has to be above the children
- When the parent uses the data itself, not just for prop drilling
**Test for "should this be lifted?":** if the parent doesn't use the data — only renders children that do — push the fetch down.
Reference: [TkDodo — Practical React Query](https://tkdodo.eu/blog/practical-react-query) | [React — Lifting State Up](https://react.dev/learn/sharing-state-between-components)
references/render-stable-query-keys.md
---
title: Stabilize Object-Shaped Query Keys
impact: MEDIUM
impactDescription: prevents new fetch on every parent render
tags: render, query-keys, usememo, stability
---
## Stabilize Object-Shaped Query Keys
`queryKey: ['products', { filter: 'active', sort: 'name' }]` looks stable — but if `{ filter: 'active', sort: 'name' }` is created inline, a new object reference exists on every render. While TanStack Query does deep-compare keys (it tolerates new-but-equal objects), upstream code (memoized selectors, custom hooks, dependency arrays in `useEffect`) often does reference equality. The result: queries appear to refetch needlessly, or downstream `useEffect`s fire on every parent render.
Stabilize complex keys with `useMemo` keyed on their primitive inputs. The key becomes a stable reference whose identity changes only when its inputs do.
**Incorrect (key recreated every render — downstream comparisons fail):**
```tsx
function ProductList({ category, sort }: Props) {
// New object reference every render of ProductList
const { data } = useQuery({
queryKey: ['products', { category, sort }],
queryFn: () => fetchProducts({ category, sort }),
});
// Downstream consumers see a "different" key each render even though values are equal
useEffect(() => {
track('view', { category, sort }); // fires every render! 🚨
}, [{ category, sort }]); // ← inline object is never === itself
}
```
**Correct (memoize the key object):**
```tsx
function ProductList({ category, sort }: Props) {
const filters = useMemo(() => ({ category, sort }), [category, sort]);
const { data } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
});
useEffect(() => {
track('view', filters);
}, [filters]); // stable; fires only when category or sort actually change
}
```
**Better: factor key construction into a hook with a key factory:**
```tsx
// src/queries/use-products.ts
export const productKeys = {
list: (filters: Filters) => ['products', 'list', filters] as const,
};
export function useProducts(filters: Filters) {
const stableFilters = useMemo(
() => filters,
[filters.category, filters.sort, filters.minPrice]
);
return useQuery({
queryKey: productKeys.list(stableFilters),
queryFn: () => fetchProducts(stableFilters),
});
}
// Consumer code is clean and the key construction is reusable + testable
function ProductList(props: Props) {
const { data } = useProducts({ category: props.category, sort: props.sort });
}
```
**For computed/derived keys, push computation into useMemo:**
```tsx
// Filter pipeline that produces a complex key
const filters = useMemo(
() => ({
...baseFilters,
expandedCategories: expandCategoryTree(baseFilters.category, categoryTree),
locale: i18n.locale,
}),
[baseFilters, categoryTree, i18n.locale]
);
```
**Watch for:**
- `Date` objects in keys — `new Date()` per render produces non-equal objects; freeze it or use ISO strings
- `Set` and `Map` — they're never structurally equal to a new copy; use plain objects or arrays
- Functions in keys — never. Functions break serialization, and inline functions change every render
**For very large state objects, hash them:**
```tsx
import { hash } from 'object-hash';
const filterHash = useMemo(() => hash(complexFilters), [complexFilters]);
useQuery({
queryKey: ['report', filterHash],
queryFn: () => fetchReport(complexFilters),
});
```
Reference: [TanStack Query — Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) | [TkDodo — Status Checks in React Query](https://tkdodo.eu/blog/status-checks-in-react-query)
references/render-suspense-per-section.md
---
title: Place Suspense Boundaries Per Logical Section
impact: MEDIUM
impactDescription: enables independent streaming of data sections
tags: render, suspense, boundaries, streaming, isolation
---
## Place Suspense Boundaries Per Logical Section
A single Suspense boundary at the route root suspends the entire page until *every* query resolves. The article is ready in 50ms, recommendations take 400ms, and the user stares at a full-page skeleton for 400ms. Suspense per section lets each section reveal as its own data lands — the article appears immediately, recommendations follow when ready. The page streams in.
This is also what SSR streaming (RSC, Suspense streaming) requires: each Suspense boundary becomes a streamable unit in the HTML response.
**Incorrect (one boundary — whole page waits for the slowest fetch):**
```tsx
function ArticlePage({ id }: { id: string }) {
return (
<Suspense fallback={<FullPageSkeleton />}>
<Article id={id} /> {/* 50ms */}
<Comments postId={id} /> {/* 150ms */}
<RelatedArticles articleId={id} /> {/* 400ms — page waits for THIS */}
</Suspense>
);
// User sees the skeleton for 400ms, then the entire page pops in at once
}
```
**Correct (boundary per section — each renders when its data lands):**
```tsx
function ArticlePage({ id }: { id: string }) {
return (
<>
<Suspense fallback={<ArticleSkeleton />}>
<Article id={id} /> {/* paints at 50ms */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={id} /> {/* paints at 150ms */}
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedArticles articleId={id} /> {/* paints at 400ms */}
</Suspense>
</>
);
// Article visible at 50ms, comments at 150ms, related at 400ms
// Time to first meaningful paint: 50ms vs 400ms
}
```
**Pair with useSuspenseQuery (suspends until data is available):**
```tsx
function Article({ id }: { id: string }) {
const { data } = useSuspenseQuery({
queryKey: ['article', id],
queryFn: () => fetchArticle(id),
});
// data is non-nullable — Suspense above handled loading
return <ArticleBody article={data} />;
}
```
**Combine with [[resilience-scoped-error-boundaries]] for the full pattern:**
```tsx
function ArticlePage({ id }: { id: string }) {
return (
<>
<ErrorBoundary fallback={<ArticleError />}>
<Suspense fallback={<ArticleSkeleton />}>
<Article id={id} />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={null /* hide on failure */}>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedArticles articleId={id} />
</Suspense>
</ErrorBoundary>
</>
);
}
```
**Suspense streaming with RSC (Next.js App Router):**
```tsx
// app/article/[id]/page.tsx
export default function ArticlePage({ params }: { params: { id: string } }) {
return (
<>
{/* Server fetches article synchronously — included in initial HTML */}
<Article id={params.id} />
{/* These stream in after initial HTML, each as its own chunk */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={params.id} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedArticles articleId={params.id} />
</Suspense>
</>
);
}
```
**Boundary placement rules:**
- One per *independent* data section — sections with no shared loading dependency
- Not per individual query — too granular leads to skeleton churn
- Below the main content boundary that handles the route's critical data — failures in optional sections don't cascade up
**Don't over-nest:** nested Suspense boundaries cascade fallbacks. If a child's data takes 100ms and its parent boundary handles a 50ms fetch, the child reveals after both — total perceived: 150ms.
Reference: [React — Suspense](https://react.dev/reference/react/Suspense) | [Next.js — Streaming](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming)
references/resilience-abort-on-unmount.md
---
title: Abort Requests on Unmount or Navigation
impact: HIGH
impactDescription: prevents stale-response races and memory leaks
tags: resilience, abort-controller, cancellation, race-conditions
---
## Abort Requests on Unmount or Navigation
When a user types "a" in a search box (fires request 1, 800ms), then "ab" (fires request 2, 100ms), the responses arrive out of order: request 2 lands first, then request 1 overwrites it. The UI shows results for "a" while the user is staring at "ab". `AbortController` cancels the previous request when a new one starts — and it also cancels in-flight requests when components unmount, preventing the "setState on unmounted component" warning.
TanStack Query and SWR pass an `AbortSignal` to your queryFn automatically — your job is to forward it to `fetch`.
**Incorrect (stale response races overwrite fresh state):**
```tsx
function Search({ term }: { term: string }) {
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
fetch(`/search?q=${term}`).then(r => r.json()).then(setResults);
// Two quick typings race; whichever resolves last wins
}, [term]);
}
```
**Correct (forward AbortSignal — automatic cancellation):**
```tsx
function Search({ term }: { term: string }) {
const { data } = useQuery({
queryKey: ['search', term],
queryFn: async ({ signal }) => {
const res = await fetch(`/search?q=${encodeURIComponent(term)}`, { signal });
if (!res.ok) throw new Error(`search failed: ${res.status}`);
return res.json();
},
enabled: term.length > 0,
});
}
```
**Manual AbortController (when not using a query library):**
```tsx
function Search({ term }: { term: string }) {
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
const ctrl = new AbortController();
fetch(`/search?q=${term}`, { signal: ctrl.signal })
.then(r => r.json())
.then(setResults)
.catch(e => {
if (e.name !== 'AbortError') throw e; // expected on cancel
});
return () => ctrl.abort(); // cancel on unmount or term change
}, [term]);
}
```
**Timeout via AbortSignal.timeout (built-in):**
```ts
const res = await fetch('/slow-endpoint', {
signal: AbortSignal.timeout(5000), // auto-aborts after 5s
});
```
**Combine multiple cancellation sources:**
```ts
const userCtrl = new AbortController();
const signal = AbortSignal.any([userCtrl.signal, AbortSignal.timeout(5000)]);
const res = await fetch(url, { signal });
// Cancels if EITHER the user navigates away OR the 5s timeout fires
```
**Don't forget the server side:** an aborted client request frees the client, but if the server doesn't notice the closed connection, it'll keep computing the response. For expensive endpoints, propagate cancellation server-side (e.g., Express `req.on('close', ...)` to abort the upstream query).
Reference: [MDN — AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) | [TanStack Query — Query Cancellation](https://tanstack.com/query/latest/docs/framework/react/guides/query-cancellation)
references/resilience-bounded-timeouts.md
---
title: Bound Request Timeouts per Endpoint Class
impact: HIGH
impactDescription: prevents indefinite hangs on degraded backends
tags: resilience, timeout, abort-signal, sla
---
## Bound Request Timeouts per Endpoint Class
A request that never resolves blocks UI updates, retains memory, and holds a connection slot indefinitely. `fetch` has no default timeout — without explicit bounds, a stuck backend produces stuck clients. Set a timeout per endpoint class: typeahead 1-2s, list views 3-5s, deep queries 10s. When the timeout fires, the user gets a clear failure path instead of a forever spinner.
Use `AbortSignal.timeout()` (widely supported in 2026) or combine `AbortController` with `setTimeout`.
**Incorrect (no timeout — hung request blocks UI forever):**
```ts
async function fetchProduct(id: string): Promise<Product> {
const res = await fetch(`/api/products/${id}`); // hangs if backend is unresponsive
return res.json();
}
```
**Correct (per-endpoint timeout):**
```ts
const TIMEOUTS = {
typeahead: 1500, // user is waiting — fail fast and let them retry
list: 5000, // moderate load
detail: 8000, // deeper queries allowed more headroom
export: 30_000, // long-running operations
} as const;
async function fetchProduct(id: string, { signal }: { signal?: AbortSignal } = {}) {
const merged = signal
? AbortSignal.any([signal, AbortSignal.timeout(TIMEOUTS.detail)])
: AbortSignal.timeout(TIMEOUTS.detail);
try {
const res = await fetch(`/api/products/${id}`, { signal: merged });
if (!res.ok) throw new HttpError(res.status, res.statusText);
return res.json();
} catch (e) {
if ((e as Error).name === 'TimeoutError') {
throw new Error(`product fetch timed out after ${TIMEOUTS.detail}ms`);
}
throw e;
}
}
```
**Polyfill `AbortSignal.timeout` if needed (older runtimes):**
```ts
export function timeoutSignal(ms: number): AbortSignal {
if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(ms);
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(new DOMException('timeout', 'TimeoutError')), ms);
return ctrl.signal;
}
```
**Show progress before failure (degrade gracefully):**
```tsx
function ProductPage({ id }: { id: string }) {
const { data, error, isLoading } = useQuery({
queryKey: ['product', id],
queryFn: ({ signal }) => fetchProduct(id, { signal }),
retry: 1,
});
// After 2s of loading, surface a "this is taking longer than usual" hint
const [slow, setSlow] = useState(false);
useEffect(() => {
if (!isLoading) return;
const t = setTimeout(() => setSlow(true), 2000);
return () => clearTimeout(t);
}, [isLoading]);
if (error?.message.includes('timed out')) return <TimeoutError onRetry={refetch} />;
if (isLoading) return slow ? <SlowLoadingState /> : <Skeleton />;
return <Product product={data!} />;
}
```
**Server-side timeouts (don't waste compute on requests that already gave up):**
```ts
// On the server, propagate the client AbortSignal to the database query
app.get('/api/products/:id', async (req, res) => {
const signal = AbortSignal.timeout(5000); // server-side bound too
req.on('close', () => signal); // client disconnected — cancel
const product = await db.products.findUnique({ where: { id: req.params.id }, signal });
res.json(product);
});
```
Reference: [MDN — AbortSignal.timeout()](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static)
references/resilience-graceful-degradation.md
---
title: Gracefully Degrade Non-Critical Sections
impact: MEDIUM-HIGH
impactDescription: preserves core flow when peripheral data fails
tags: resilience, graceful-degradation, isolation, optional
---
## Gracefully Degrade Non-Critical Sections
Not all data is equal. The user came to read the article — the related-articles carousel, the inline ads, the social count, the "people also viewed" rail are all decorations. When they fail, the core experience should still work. The pattern: classify each data fetch as critical or optional at design time, and let optional sections fail silently (rendering `null` or a minimal placeholder).
The contrast: ungated optional content shows error states that confuse the user about what's broken, and worse, can cascade through error boundaries to break the page entirely.
**Incorrect (all data sections treated equally — peripheral failure breaks layout):**
```tsx
function ArticlePage({ id }: { id: string }) {
const article = useQuery({ queryKey: ['article', id], queryFn: () => fetchArticle(id) });
const related = useQuery({ queryKey: ['related', id], queryFn: () => fetchRelated(id) });
const ads = useQuery({ queryKey: ['ads', id], queryFn: () => fetchAds(id) });
// If any of these is loading/error, we render their loading/error states
if (article.isLoading) return <Skeleton />;
if (article.error || related.error || ads.error) return <ErrorPage />; // 🚨 ads failed → no article
return <>{/* render all */}</>;
}
```
**Correct (criticality-aware rendering):**
```tsx
function ArticlePage({ id }: { id: string }) {
// Critical — must succeed
const article = useQuery({
queryKey: ['article', id],
queryFn: () => fetchArticle(id),
throwOnError: true, // bubble to ErrorBoundary
});
// Optional — failure tolerated
const related = useQuery({
queryKey: ['related', id],
queryFn: () => fetchRelated(id),
throwOnError: false, // never bubbles
retry: 1,
});
const ads = useQuery({
queryKey: ['ads', id],
queryFn: () => fetchAds(id),
throwOnError: false,
retry: 0,
});
if (article.isLoading) return <ArticleSkeleton />;
// article.error → handled by parent ErrorBoundary
return (
<>
<Article data={article.data!} />
{/* Optional sections render only if data is present; silent on failure */}
{related.data && <RelatedCarousel items={related.data} />}
{ads.data && <Ads items={ads.data} />}
</>
);
}
```
**Decoration helper:**
```ts
export function useOptionalQuery<T>(
options: UseQueryOptions<T> & { queryKey: QueryKey; queryFn: QueryFunction<T> }
) {
return useQuery({
...options,
throwOnError: false,
retry: 1,
// Optional data should fail fast — don't burn retry budget
retryDelay: 1000,
});
}
// Now usage is intent-revealing:
const related = useOptionalQuery({ queryKey: ['related', id], queryFn: () => fetchRelated(id) });
```
**Three-tier classification:**
| Tier | Examples | On failure |
|------|----------|-----------|
| Critical | Article content, account info, cart contents | Throw to error boundary; user must retry |
| Important | Comments, follow status, related items | Show degraded placeholder; allow retry |
| Decorative | Ads, recommendation carousels, social proof counts | Render `null`; silent failure |
**Combine with [[resilience-scoped-error-boundaries]]:** scoped boundaries protect against unexpected errors; tier classification handles expected partial failure.
**Log silent failures:** even when not user-visible, send the error to your observability stack. A silently-failing recommendation engine still needs fixing.
Reference: [Resilient Web Design — Jeremy Keith](https://resilientwebdesign.com/) | [Vercel — Building resilient UIs](https://vercel.com/blog/everything-about-data-fetching-in-nextjs)
references/resilience-no-auto-retry-mutations.md
---
title: Avoid Auto-Retrying Non-Idempotent Mutations
impact: HIGH
impactDescription: prevents double-charging, duplicate posts, double sends
tags: resilience, retry, mutations, idempotency
---
## Avoid Auto-Retrying Non-Idempotent Mutations
A retry of `GET /products/123` is safe — it returns the same thing. A retry of `POST /charge` may bill the customer twice. The risk isn't only on network failure: the request *may have succeeded* but the response was lost. The client sees "no response" → retries → server processes a second charge. Default mutation retry behavior in some clients defaults to retry, which silently turns rare race conditions into duplicate side effects.
Make mutations safe to retry by giving them an idempotency key, OR don't retry them at all. Decide per-mutation.
**Incorrect (auto-retry on a non-idempotent mutation):**
```tsx
const charge = useMutation({
mutationFn: createCharge,
retry: 3, // ❌ if the network drops after the server processed the charge,
// the retry processes a SECOND charge
});
```
**Correct (no auto-retry — let the user decide):**
```tsx
const charge = useMutation({
mutationFn: createCharge,
retry: false, // ← TanStack Query default is false for mutations; keep it
onError: (err) => toast.error(`Charge failed — please try again`),
});
// User-initiated retry: a click; the user is aware they're trying again
<Button onClick={() => charge.mutate(params)} disabled={charge.isPending}>
{charge.isError ? 'Retry payment' : 'Pay'}
</Button>
```
**Better: make the mutation safe to retry with an idempotency key:**
```ts
async function createCharge(params: ChargeParams): Promise<Charge> {
return fetch('/api/charge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': params.idempotencyKey, // ← server dedupes by this key
},
body: JSON.stringify(params),
}).then(r => r.json());
}
const charge = useMutation({
mutationFn: createCharge,
retry: 2, // SAFE because the server enforces idempotency
});
// Generate the key once per attempt — same key across retries of the same logical operation
function PaymentForm() {
const idempotencyKey = useRef(crypto.randomUUID());
return (
<Button onClick={() => charge.mutate({ ...formValues, idempotencyKey: idempotencyKey.current })}>
Pay
</Button>
);
}
```
**Server-side enforcement (the actual idempotency):**
```ts
// Pseudocode for the API handler
async function handleCharge(req: Request) {
const key = req.headers.get('Idempotency-Key');
if (!key) return error('Idempotency-Key required for mutations');
const cached = await redis.get(`idempotency:${key}`);
if (cached) return Response.json(JSON.parse(cached)); // same response, no second charge
const result = await processCharge(req.body);
await redis.setex(`idempotency:${key}`, 86400, JSON.stringify(result));
return Response.json(result);
}
```
**Mutation categories (decide retry per-category):**
| Mutation type | Auto-retry? | Notes |
|---------------|-------------|-------|
| Idempotent updates (PUT with full state) | Yes | `PUT /users/:id` with full object is idempotent by HTTP spec |
| DELETE | Usually yes | Re-deleting an already-deleted thing returns 404 — handle that as success |
| POST without idempotency key | No | User confirms via UI |
| POST with idempotency key | Yes | Server enforces dedup |
| POST with side effects (email, notification) | Only with idempotency key | Double emails are user-visible |
Reference: [Stripe — Idempotent Requests](https://docs.stripe.com/api/idempotent_requests) | [TanStack Query — Mutations](https://tanstack.com/query/latest/docs/framework/react/guides/mutations)
references/resilience-scoped-error-boundaries.md
---
title: Scope Error Boundaries to Data Sections
impact: HIGH
impactDescription: prevents one fetch failure from breaking the entire page
tags: resilience, error-boundary, suspense, isolation
---
## Scope Error Boundaries to Data Sections
A single error boundary at the route root means *any* failed fetch crashes the entire page. The user came to read the article and the related-articles carousel failed — now they see a generic error page instead of the article. Scope error boundaries to *each independent data section*: when the carousel fails, the article still renders, and only the carousel shows a "couldn't load" placeholder.
Use one boundary per Suspense boundary. The combination is "this chunk has its own loading state AND its own error state."
**Incorrect (one top-level boundary — failures cascade to whole page):**
```tsx
function ArticlePage() {
return (
<ErrorBoundary fallback={<GenericError />}>
<Suspense fallback={<FullPageSkeleton />}>
<Article />
<Comments />
<RelatedArticles /> {/* if this throws → entire page becomes <GenericError /> */}
</Suspense>
</ErrorBoundary>
);
}
```
**Correct (boundary per data section — failures stay local):**
```tsx
function ArticlePage() {
return (
<>
<SectionBoundary name="article" fallback={<ArticleError />}>
<Suspense fallback={<ArticleSkeleton />}>
<Article />
</Suspense>
</SectionBoundary>
<SectionBoundary name="comments" fallback={<CommentsCollapsed />}>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</SectionBoundary>
<SectionBoundary name="related" fallback={null /* silently hide */}>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedArticles />
</Suspense>
</SectionBoundary>
</>
);
// Related fails → silently hidden. Article still renders. User reads the page.
}
```
**Reusable boundary with retry + reporting:**
```tsx
function SectionBoundary({
name, fallback, children,
}: { name: string; fallback: ReactNode; children: ReactNode }) {
return (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<>
{typeof fallback === 'function' ? fallback({ error, retry: resetErrorBoundary }) : fallback}
</>
)}
onError={(error) => reportError({ section: name, error })}
>
{children}
</ErrorBoundary>
);
}
```
**Critical-vs-optional section design:**
| Section type | Boundary fallback | Why |
|--------------|------------------|-----|
| Critical (the article itself) | Show error with retry | User came for this; tell them what failed |
| Important (comments) | Collapsed/minimal placeholder | Visible degradation; user knows it failed |
| Optional (recommendations) | `null` or hidden | Don't visually punish the user for an irrelevant failure |
**Catch async errors in event handlers explicitly:** error boundaries catch render-time errors, not promise rejections from event handlers. Use `try/catch` in handlers and surface failures via toast or local state.
Reference: [React — Error Boundaries](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) | [react-error-boundary](https://github.com/bvaughn/react-error-boundary)
references/resilience-stale-fallback.md
---
title: Fall Back to Stale Cache When Fresh Fetch Fails
impact: HIGH
impactDescription: prevents temporary outages from becoming visible errors
tags: resilience, stale, fallback, cache, stale-if-error
---
## Fall Back to Stale Cache When Fresh Fetch Fails
If a recommendations endpoint flakes for 30 seconds, three options exist: (a) show an error and hide the carousel, (b) show a skeleton forever, (c) show the previously-cached recommendations and quietly retry in the background. Option (c) is the right answer for most non-critical data — the user sees content, the failure is invisible, and a successful retry replaces the stale data with fresh.
This is the `stale-if-error` directive from RFC 5861 applied client-side. Both TanStack Query and SWR can expose stale data on error; you just have to read `error` and `data` simultaneously.
**Incorrect (binary: success or error — no stale fallback):**
```tsx
function Recommendations() {
const { data, error } = useQuery({
queryKey: ['recommendations'],
queryFn: fetchRecommendations,
});
if (error) return <ErrorBanner />;
if (!data) return <Skeleton />;
return <Carousel items={data} />;
}
// Endpoint flakes for 30s → user sees ErrorBanner for 30s for stale-but-fine data
```
**Correct (render stale on error, retry silently in background):**
```tsx
function Recommendations() {
// `data` holds the last-successful response even when the most recent fetch failed
const { data, error, isError, refetch } = useQuery({
queryKey: ['recommendations'],
queryFn: fetchRecommendations,
retry: 2,
retryDelay: attempt => Math.min(30_000, 1000 * 2 ** attempt) * Math.random(),
});
// We have stale data — render it even if the latest fetch errored
if (data) {
return (
<>
<Carousel items={data} />
{isError && <small className="text-muted">(showing previous results)</small>}
</>
);
}
// No stale data either — only now show the failure
if (error) return <ErrorBanner onRetry={refetch} />;
return <Skeleton />;
}
```
**Server-side parallel (HTTP header):**
```http
Cache-Control: public, max-age=60, stale-if-error=86400
# If the origin returns 5xx, the cache serves stale content for up to 24h
```
**With persistence (survive page reload):**
```tsx
// Persist the query cache to localStorage for resilience across refreshes
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
persistQueryClient({
queryClient,
persister: createSyncStoragePersister({ storage: window.localStorage }),
maxAge: 24 * 60 * 60_000, // serve stale-up-to-1-day data after a reload
});
```
**When NOT to fall back to stale:**
- Cart contents and order totals — staleness can mislead at checkout
- Inventory (showing "in stock" when it's not creates support load)
- User-permission-sensitive data (showing the wrong account's data is a security issue)
**Tell the user the data is stale:** a tiny "last updated 2 min ago" timestamp lets them know they're seeing cached data without breaking the experience. Hidden staleness is technical debt; visible staleness is graceful degradation.
Reference: [RFC 5861 — stale-if-error](https://datatracker.ietf.org/doc/html/rfc5861#section-4) | [TanStack Query Persist Client](https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient)
SKILL.md
---
name: react-fetch-cache-patterns
description: React data-fetching patterns at scale — recommender carousels, infinite feeds, pages with many parallel fetches, dashboards. Covers request orchestration (parallelism, batching, deduplication), cache strategy (keys, normalization, staleTime, SWR), backend protection (concurrency caps, debounce/throttle, jittered retries, circuit breakers), prefetching (route loaders, hover/intent, idle, server hydration), failure resilience (AbortController, timeouts, error boundaries, stale fallback, idempotent mutations), and feed/carousel patterns (virtualization, cursor pagination, summary/detail split). Includes 5 ready-to-use scaffolding templates (resource query hook, carousel data loader, infinite feed, hover-prefetch link, request collapser). Trigger when building, reviewing, or refactoring React components that fetch data — even if the user doesn't explicitly mention "performance" or "scale".
---
# Experimental React Data Fetching & Caching Best Practices
Implementation patterns for React applications that fetch and cache many API requests without overwhelming the backend. **48 rules across 8 categories**, ordered by execution lifecycle impact — earlier categories cascade through everything downstream. Templates show both library-based (TanStack Query, SWR) **and library-free** (pure React + AbortController) implementations so the patterns are usable regardless of stack constraints.
## When to Apply
- Writing or reviewing a React component that calls `fetch`, `useQuery`, `useSWR`, or any data-fetching hook
- Designing a list, feed, or carousel that displays many items each requiring data
- Investigating "the backend is getting hammered" or "the page loads slowly" symptoms
- Choosing between client-side fetching, route loaders, server components, or SSR
- Implementing prefetch, retry, optimistic updates, or any failure-handling logic
- Refactoring code that already does data fetching but with waterfalls, no cache strategy, or no concurrency limits
## Rule Categories by Priority
| # | Category | Impact | Prefix | Rules |
|---|----------|--------|--------|-------|
| 1 | Request Orchestration | CRITICAL | `orch-` | 7 |
| 2 | Cache Strategy | CRITICAL | `cache-` | 7 |
| 3 | Backend Protection | CRITICAL | `protect-` | 7 |
| 4 | Prefetch & Hydration | HIGH | `prefetch-` | 6 |
| 5 | Failure Resilience | HIGH | `resilience-` | 6 |
| 6 | Feed & Carousel Patterns | MEDIUM-HIGH | `feed-` | 7 |
| 7 | Mutation & Invalidation | MEDIUM | `mutate-` | 4 |
| 8 | Component Patterns | MEDIUM | `render-` | 4 |
## Quick Reference
### 1. Request Orchestration (CRITICAL)
- [`orch-parallelize-independent-fetches`](references/orch-parallelize-independent-fetches.md) — Use `Promise.all` for independent requests; never serial `await`
- [`orch-batch-n-plus-one-fanout`](references/orch-batch-n-plus-one-fanout.md) — Collapse per-row fetches via DataLoader-style batching
- [`orch-dedupe-in-flight-requests`](references/orch-dedupe-in-flight-requests.md) — One in-flight request per key, shared across subscribers
- [`orch-lift-fetch-to-route-loader`](references/orch-lift-fetch-to-route-loader.md) — Fetch in parallel with route chunk download
- [`orch-avoid-effect-chains`](references/orch-avoid-effect-chains.md) — Flatten the dependency graph; only true dependencies wait
- [`orch-server-fetch-when-possible`](references/orch-server-fetch-when-possible.md) — Move fetches to RSC/server when they don't depend on client state
- [`orch-prefer-bulk-endpoint-for-fanout`](references/orch-prefer-bulk-endpoint-for-fanout.md) — One bulk request beats N parallel requests
### 2. Cache Strategy (CRITICAL)
- [`cache-deterministic-keys`](references/cache-deterministic-keys.md) — Canonicalize cache keys; strip undefined, sort arrays
- [`cache-normalize-shared-entities`](references/cache-normalize-shared-entities.md) — Store each entity once; views hold references
- [`cache-set-stale-time`](references/cache-set-stale-time.md) — Tune `staleTime` to data volatility, not refresh frequency
- [`cache-stale-while-revalidate`](references/cache-stale-while-revalidate.md) — Render stale instantly; revalidate in background
- [`cache-select-subscribed-fields`](references/cache-select-subscribed-fields.md) — Subscribe to slices, not whole objects
- [`cache-shared-key-factory`](references/cache-shared-key-factory.md) — One typed source of truth for keys; prevents read/write drift
- [`cache-tiered-stale-fresh`](references/cache-tiered-stale-fresh.md) — Different `staleTime` per data class (realtime, fresh, warm, cold, static)
### 3. Backend Protection (CRITICAL)
- [`protect-concurrency-limit-fanout`](references/protect-concurrency-limit-fanout.md) — Cap simultaneous requests with `p-limit`/semaphore
- [`protect-collapse-identical-requests`](references/protect-collapse-identical-requests.md) — In-flight dedup at the fetch layer
- [`protect-debounce-user-driven-fetches`](references/protect-debounce-user-driven-fetches.md) — Wait for user pause before firing search/filter requests
- [`protect-throttle-scroll-triggered`](references/protect-throttle-scroll-triggered.md) — Use IntersectionObserver for viewport-triggered fetches
- [`protect-jittered-retry-backoff`](references/protect-jittered-retry-backoff.md) — Add random jitter to prevent thundering-herd retries
- [`protect-circuit-breaker`](references/protect-circuit-breaker.md) — Stop calling persistently failing endpoints for a cooldown window
- [`protect-rate-limit-aware-client`](references/protect-rate-limit-aware-client.md) — Honor `Retry-After` and `X-RateLimit-*` headers
### 4. Prefetch & Hydration (HIGH)
- [`prefetch-hover-intent-links`](references/prefetch-hover-intent-links.md) — Prefetch on hover/pointerdown for instant navigation
- [`prefetch-parallel-loader-queries`](references/prefetch-parallel-loader-queries.md) — Parallelize independent queries inside loaders
- [`prefetch-hydrate-server-cache`](references/prefetch-hydrate-server-cache.md) — Ship server-fetched data via `HydrationBoundary` / RSC
- [`prefetch-idle-likely-next`](references/prefetch-idle-likely-next.md) — Use `requestIdleCallback` for likely-next data
- [`prefetch-viewport-triggered-next-page`](references/prefetch-viewport-triggered-next-page.md) — Fire next-page prefetch via `rootMargin`
- [`prefetch-budget-and-priority`](references/prefetch-budget-and-priority.md) — Tier prefetches by priority; respect `Save-Data`
### 5. Failure Resilience (HIGH)
- [`resilience-abort-on-unmount`](references/resilience-abort-on-unmount.md) — Forward `AbortSignal` to cancel stale fetches
- [`resilience-bounded-timeouts`](references/resilience-bounded-timeouts.md) — Set per-endpoint timeouts via `AbortSignal.timeout()`
- [`resilience-scoped-error-boundaries`](references/resilience-scoped-error-boundaries.md) — One boundary per data section, not per page
- [`resilience-stale-fallback`](references/resilience-stale-fallback.md) — Render stale cache when fresh fetch fails
- [`resilience-no-auto-retry-mutations`](references/resilience-no-auto-retry-mutations.md) — Use idempotency keys or don't auto-retry
- [`resilience-graceful-degradation`](references/resilience-graceful-degradation.md) — Critical/important/decorative tiers with different failure modes
### 6. Feed & Carousel Patterns (MEDIUM-HIGH)
- [`feed-virtualize-long-lists`](references/feed-virtualize-long-lists.md) — Use TanStack Virtual for lists > 50 items
- [`feed-cursor-pagination`](references/feed-cursor-pagination.md) — Cursors beat offset for inserts and large offsets
- [`feed-split-summary-from-detail`](references/feed-split-summary-from-detail.md) — Carousel summaries lightweight; detail on demand
- [`feed-multi-carousel-isolation`](references/feed-multi-carousel-isolation.md) — Per-carousel error/Suspense boundaries + tiered fallbacks for homepage feeds
- [`feed-stable-keys-across-pages`](references/feed-stable-keys-across-pages.md) — Use entity IDs as keys; never index
- [`feed-image-lazy-and-sized`](references/feed-image-lazy-and-sized.md) — `loading="lazy"` + explicit dimensions
- [`feed-bounded-working-set`](references/feed-bounded-working-set.md) — `maxPages` + entity LRU eviction for unbounded feeds
### 7. Mutation & Invalidation (MEDIUM)
- [`mutate-optimistic-updates-with-rollback`](references/mutate-optimistic-updates-with-rollback.md) — Snapshot, optimistic write, rollback on error
- [`mutate-surgical-invalidation`](references/mutate-surgical-invalidation.md) — Invalidate specific keys, not entire trees
- [`mutate-set-data-over-invalidate`](references/mutate-set-data-over-invalidate.md) — Write mutation responses directly into the cache
- [`mutate-cancel-queries-on-mutate`](references/mutate-cancel-queries-on-mutate.md) — Cancel in-flight queries before optimistic writes
### 8. Component Patterns (MEDIUM)
- [`render-stable-query-keys`](references/render-stable-query-keys.md) — `useMemo` object keys to keep references stable
- [`render-cap-fanout-in-lists`](references/render-cap-fanout-in-lists.md) — Lift fetches; batch via DataLoader; virtualize
- [`render-suspense-per-section`](references/render-suspense-per-section.md) — One Suspense boundary per data section
- [`render-colocate-fetch-with-consumer`](references/render-colocate-fetch-with-consumer.md) — Put `useQuery` next to its consumer, not at the root
## How to Use
1. Open [references/_sections.md](references/_sections.md) for category definitions and impact rationale
2. Read individual rule files for incorrect-vs-correct code examples
3. For ready-to-use scaffolds, see [scaffolding templates](assets/templates/)
4. The [AGENTS.md](AGENTS.md) navigation document (auto-generated) provides a TOC for browsing
## Scaffolding Templates
Six ready-to-adapt code templates under `assets/templates/`:
| Template | Library deps | Purpose |
|----------|-------------|---------|
| `use-resource-query.template.tsx` | TanStack Query | Standard query hook with key factory, retry, abort, optional suspense |
| `use-resource-query-no-deps.template.tsx` | **None** (pure React + AbortController) | Same patterns as above, library-free: hand-rolled cache, dedup, retry with jitter, staleTime/gcTime, concurrency limit |
| `carousel-data-loader.template.tsx` | TanStack Query + react-error-boundary | Single carousel (summary + viewport-triggered detail) *and* multi-carousel feed with per-carousel failure isolation |
| `infinite-feed.template.tsx` | TanStack Query + TanStack Virtual | Cursor-paginated infinite feed with virtualization and bounded working set |
| `prefetch-link.template.tsx` | None | Hover/intent prefetch link wrapper |
| `request-collapser.template.ts` | None | In-flight deduplication + concurrency limit utility |
**Library-free path:** if you can't add TanStack Query / SWR / DataLoader to your bundle (size constraints, host-app conflicts, dependency bans), start with `use-resource-query-no-deps.template.tsx` — it implements the core cache/dedup/retry/abort patterns in ~250 lines using only React and the web platform. The other templates that depend on TanStack can be adapted on top of it; only the `request-collapser` and `prefetch-link` templates are zero-dep out of the box.
## Reference Files
| File | Description |
|------|-------------|
| [references/_sections.md](references/_sections.md) | Category definitions, ordering, impact rationale |
| [assets/templates/_template.md](assets/templates/_template.md) | Template for authoring new rules |
| [metadata.json](metadata.json) | Version, references, abstract |
## Related Skills
- `react-optimise` — General React render performance (this skill is data-fetching-specific)
- `nextjs-bundle-optimizer` — Bundle/payload optimization for Next.js
- `inngest-nextjs-patterns` — Server-side workflow patterns (complements server-fetch guidance)