DNAπŸ—οΈ System DesignData Fetching & Caching Strategies
πŸ¦–DinosaurSystem DesignData FetchingCaching

Data Fetching & Caching Strategies

How data gets from server to screen β€” and how to make it feel instant. Fetching patterns, caching architectures, and the trade-offs behind every choice.

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

FactorRESTGraphQLtRPCgRPC-Web
OverfetchingCommonSolvedSolvedProtocol buffers
Type safetyManual (OpenAPI)Schema + codegenEnd-to-end TSProto + codegen
CachingHTTP-native (ETags, Cache-Control)Normalized client cacheReact QueryHTTP caching
Real-timePolling / SSE bolt-onSubscriptions built-inSubscriptionsStreaming
Learning curveLowMediumLow (TS required)High
Tooling maturityExcellentGoodGrowingModerate
Best forPublic APIs, caching-heavyComplex nested data, multiple consumersFull-stack TypeScript monorepoHigh-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 disaster

Parallel 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 it

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

StrategyWhen to UseExample
Time-basedPredictable stalenessstaleTime: 60_000 β€” news feed
Event-basedAfter mutationsInvalidate posts after creating one
Tag-basedRelated data groupsInvalidate all ['user', userId] queries
PollingNear-real-time needsrefetchInterval: 5_000 for live scores
WebSocket-drivenTrue real-timeServer 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 page
const {
  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:

  1. Query keys as cache addresses β€” ['posts', { page: 2, sort: 'date' }] is a unique cache entry
  2. Structural sharing β€” Only changed parts of data trigger re-renders
  3. Garbage collection β€” Unused queries are removed after gcTime (default 5 min)
  4. 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.