Data Fetching & Caching Strategies
The best UI in the world feels broken if data loads slowly. Data fetching architecture is the invisible backbone of frontend performance β the difference between an app that feels instant and one that feels like it's fighting the network on every click.
The Protocol Landscape
REST vs GraphQL vs tRPC vs gRPC-Web
| Factor | REST | GraphQL | tRPC | gRPC-Web |
|---|---|---|---|---|
| Overfetching | Common | Solved | Solved | Protocol buffers |
| Type safety | Manual (OpenAPI) | Schema + codegen | End-to-end TS | Proto + codegen |
| Caching | HTTP-native (ETags, Cache-Control) | Normalized client cache | React Query | HTTP caching |
| Real-time | Polling / SSE bolt-on | Subscriptions built-in | Subscriptions | Streaming |
| Learning curve | Low | Medium | Low (TS required) | High |
| Tooling maturity | Excellent | Good | Growing | Moderate |
| Best for | Public APIs, caching-heavy | Complex nested data, multiple consumers | Full-stack TypeScript monorepo | High-performance, polyglot |
When to Choose What
Public API + many clients β REST (universal, cacheable)
Complex UI with nested data β GraphQL (flexible queries)
TypeScript monorepo β tRPC (zero-cost type safety)
High-throughput microservicesβ gRPC-Web (binary, efficient)Backend For Frontend (BFF) Pattern
βββββββββββββ βββββββββββββ ββββββββββββββββββββββββββββ
β Mobile ββββββΆβ Mobile BFFββββββΆβ β
β App βββββββ βββββββ β
βββββββββββββ βββββββββββββ β Backend Microservices β
β β
βββββββββββββ βββββββββββββ β ββββββββ ββββββββββββ β
β Web ββββββΆβ Web BFF ββββββΆβ β User β β Product β β
β App βββββββ βββββββ β Svc β β Svc β β
βββββββββββββ βββββββββββββ β ββββββββ ββββββββββββ β
β ββββββββ ββββββββββββ β
β βOrder β β Search β β
β β Svc β β Svc β β
β ββββββββ ββββββββββββ β
ββββββββββββββββββββββββββββWhy BFF?
- Aggregation: One BFF call replaces 5 microservice calls
- Shaping: Response matches exactly what the UI needs β no overfetching
- Platform optimization: Mobile BFF returns smaller payloads than Web BFF
- Decoupling: Backend services can evolve without breaking the frontend
- Security: Sensitive business logic stays server-side
// Web BFF endpoint β aggregates from 3 services
app.get('/api/product-page/:id', async (req, res) => {
const [product, reviews, recommendations] = await Promise.all([
productService.getById(req.params.id),
reviewService.getByProductId(req.params.id, { limit: 5 }),
recommendationService.getForProduct(req.params.id, { limit: 8 }),
]);
res.json({
product: {
...product,
formattedPrice: formatCurrency(product.price),
inStock: product.inventory > 0,
},
reviews: {
items: reviews.items,
averageRating: reviews.aggregate.average,
totalCount: reviews.aggregate.count,
},
recommendations: recommendations.map(r => ({
id: r.id,
name: r.name,
price: formatCurrency(r.price),
thumbnail: r.images[0]?.thumbnail,
})),
});
});Data Fetching Patterns
The Waterfall Problem
Component A mounts βββΆ fetches data A βββΆ renders
β
Component B mounts βββΆ fetches data B βββΆ renders
β
Component C mounts βββΆ fetches data C
Total time: fetch(A) + fetch(B) + fetch(C) = sequential disasterParallel Fetching
// Route-level parallel fetching
async function ProductPage({ params }: { params: { id: string } }) {
const [product, reviews, related] = await Promise.all([
fetchProduct(params.id),
fetchReviews(params.id),
fetchRelated(params.id),
]);
return (
<>
<ProductDetails product={product} />
<Reviews reviews={reviews} />
<RelatedProducts items={related} />
</>
);
}Preloading
function ProductCard({ product }: { product: Product }) {
const queryClient = useQueryClient();
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['product', product.id],
queryFn: () => fetchProduct(product.id),
staleTime: 60_000,
});
};
return (
<Link
to={`/products/${product.id}`}
onMouseEnter={prefetch}
onFocus={prefetch}
>
<ProductThumbnail product={product} />
</Link>
);
}Hovering a product card prefetches its detail page. By the time the user clicks, data is already in cache. Perceived load time: zero.
Suspense-Based Fetching
function ProductPage({ id }: { id: string }) {
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails id={id} />
</Suspense>
);
}
function ProductDetails({ id }: { id: string }) {
const { data: product } = useSuspenseQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
});
return <div>{product.name}</div>;
}Suspense inverts the loading state model: instead of if (isLoading) checks inside every component, the parent boundary handles the loading UI. Cleaner component code, consistent loading states.
Caching Strategies
The Cache Strategy Spectrum
Fresh Stale
β β
ββββββββββββΌβββββββββββ¬βββββββββββββββ¬βββββββββββββββ¬βββββββββΌββββββββ
β β β β β β β
βΌ βΌ βΌ βΌ βΌ βΌ βΌ
Network Cache-then Stale-While Cache-First Cache-Only Offline
Only Network Revalidate Only
β β β β β β
β Always β Show β Show stale, β Cache wins, β Never β
β fresh β cache, β refresh in β network is β fetch β
β β update β background β fallback β β
ββββββββββββ΄βββββββββββ΄βββββββββββββββ΄βββββββββββββββ΄βββββββββStale-While-Revalidate (SWR)
The most common strategy for dynamic data. Show cached data immediately, refresh in the background:
const { data } = useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
staleTime: 30_000, // consider fresh for 30s
gcTime: 5 * 60_000, // keep in cache for 5 min
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});Timeline:
User visits page
ββ Cache HIT (data exists, < 30s old) β Show data, done
ββ Cache HIT (data exists, > 30s old) β Show stale data β refetch in background β update
ββ Cache MISS β Show loading β fetch β show data β cache itCache-Then-Network
Show cached data immediately, always fetch fresh data:
const { data } = useQuery({
queryKey: ['feed'],
queryFn: fetchFeed,
staleTime: 0, // always considered stale
placeholderData: keepPreviousData,
});Cache-First
For data that rarely changes (product categories, country lists):
const { data } = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
staleTime: Infinity, // never goes stale
gcTime: Infinity, // never garbage collected
});Cache Invalidation
Phil Karlton said there are only two hard things in CS: cache invalidation and naming things. He was right about both.
Invalidation Strategies
| Strategy | When to Use | Example |
|---|---|---|
| Time-based | Predictable staleness | staleTime: 60_000 β news feed |
| Event-based | After mutations | Invalidate posts after creating one |
| Tag-based | Related data groups | Invalidate all ['user', userId] queries |
| Polling | Near-real-time needs | refetchInterval: 5_000 for live scores |
| WebSocket-driven | True real-time | Server pushes invalidation events |
Mutation-Based Invalidation
const createPost = useMutation({
mutationFn: (newPost: CreatePostInput) => api.createPost(newPost),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
queryClient.invalidateQueries({ queryKey: ['user', 'me', 'postCount'] });
},
});Targeted Cache Updates (No Refetch)
const updateProfile = useMutation({
mutationFn: (data: UpdateProfileInput) => api.updateProfile(data),
onSuccess: (updatedUser) => {
queryClient.setQueryData(['user', 'me'], updatedUser);
},
});Pagination Strategies
Offset Pagination
GET /api/posts?offset=20&limit=10
Pros: Simple, supports "jump to page 5"
Cons: Inconsistent when data changes (skip/duplicate items)Cursor-Based Pagination
GET /api/posts?cursor=eyJpZCI6MTAwfQ&limit=10
Pros: Consistent even when data changes, better for infinite scroll
Cons: Can't jump to arbitrary pageconst {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed({ cursor: pageParam, limit: 20 }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});Infinite Scroll Implementation
function InfiniteFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeedQuery();
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: '200px' }
);
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
return (
<div>
{data?.pages.flatMap(page => page.posts).map(post => (
<PostCard key={post.id} post={post} />
))}
<div ref={loadMoreRef}>
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}The rootMargin: '200px' triggers prefetch before the user reaches the bottom, creating a seamless infinite scroll experience.
React Query / TanStack Query Architecture
βββββββββββββββββββββββββββββββββββββββββββββββ
β QueryClient β
β β
β ββββββββββββββββββββββββββββββββββββββββ β
β β Query Cache β β
β β β β
β β ['posts'] β { data, ... } β β
β β ['post', '123'] β { data, ... } β β
β β ['user', 'me'] β { data, ... } β β
β β ['comments', '123'] β { data, ... } β β
β ββββββββββββββββββββββ¬ββββββββββββββββββ β
β β β
β ββββββββββββββββββββββΌββββββββββββββββββ β
β β Query Observer β β
β β Subscribes components to cache β β
β β Triggers re-renders on updates β β
β ββββββββββββββββββββββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββββββββββββββββ β
β β Mutation Cache β β
β β Tracks in-flight mutations β β
β β Handles optimistic updates β β
β ββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββKey architectural decisions:
- Query keys as cache addresses β
['posts', { page: 2, sort: 'date' }]is a unique cache entry - Structural sharing β Only changed parts of data trigger re-renders
- Garbage collection β Unused queries are removed after
gcTime(default 5 min) - Deduplication β Multiple components using the same query key share one request
Error Handling & Retry Strategies
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof HttpError && error.status === 404) return false;
if (error instanceof HttpError && error.status === 401) return false;
return failureCount < 3;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30_000),
},
},
});Error Boundary Integration
function PostFeed() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
<p>Something went wrong loading the feed.</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
<Suspense fallback={<FeedSkeleton />}>
<FeedList />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}Real-World Prefetching Patterns
Route-Based Prefetching
const router = createBrowserRouter([
{
path: '/products/:id',
element: <ProductPage />,
loader: ({ params }) => {
queryClient.ensureQueryData({
queryKey: ['product', params.id],
queryFn: () => fetchProduct(params.id!),
});
return null;
},
},
]);Hover Prefetching with Debounce
function useHoverPrefetch(queryKey: QueryKey, queryFn: QueryFunction) {
const queryClient = useQueryClient();
const timerRef = useRef<ReturnType<typeof setTimeout>>();
return {
onMouseEnter: () => {
timerRef.current = setTimeout(() => {
queryClient.prefetchQuery({ queryKey, queryFn, staleTime: 60_000 });
}, 100);
},
onMouseLeave: () => {
clearTimeout(timerRef.current);
},
};
}The 100ms delay prevents prefetching when the cursor merely passes over a link on its way somewhere else.
Decision Framework
What data are you fetching?
β
ββ Static / rarely changes β Cache-first + long staleTime
ββ Dynamic / user-specific β SWR + mutation invalidation
ββ Real-time β WebSocket / SSE + cache sync
ββ Paginated β Infinite query + cursor pagination
How critical is freshness?
β
ββ Stock prices β Polling every second or WebSocket
ββ Social feed β SWR with 30s staleTime
ββ Product catalog β Cache-first, invalidate on admin update
ββ Static pages β Build-time generation (SSG)Data fetching architecture isn't about picking React Query and calling it done. It's about understanding the freshness requirements, choosing the right caching strategy, designing invalidation patterns, and creating a data layer that makes the UI feel instant while keeping the server honest. That's what architects do.