Data Fetching Patterns
MedData fetching in frontend system design goes far beyond simple API calls. It encompasses caching strategies, request deduplication, optimistic updates, pagination patterns, and error recovery. Choosing the right fetching pattern directly impacts perceived performance, data consistency, and user experience.
Interactive Visualization
Key Points
- Stale-While-Revalidate (SWR) serves cached data instantly then refreshes in the background
- Request deduplication prevents redundant API calls when multiple components need the same data
- Optimistic updates show changes immediately and roll back on server failure
- Cursor-based pagination scales better than offset-based for large, dynamic datasets
- Prefetching loads data before the user navigates to reduce perceived latency
- Normalized caches prevent data inconsistency when the same entity appears in multiple views
- Error retry with exponential backoff handles transient network failures gracefully
Code Examples
Stale-While-Revalidate Pattern
// SWR pattern: serve stale, then revalidate interface CacheEntry<T> { data: T timestamp: number staleTime: number } class SWRCache { private cache = new Map<string, CacheEntry<unknown>>() async fetch<T>(key: string, fetcher: () => Promise<T>, options = { staleTime: 30_000 }): Promise<T> { const cached = this.cache.get(key) as CacheEntry<T> | undefined if (cached) { const isStale = Date.now() - cached.timestamp > cached.staleTime if (isStale) { // Revalidate in background, serve stale data now fetcher().then(fresh => { this.cache.set(key, { data: fresh, timestamp: Date.now(), staleTime: options.staleTime }) }) } return cached.data } // No cache: fetch and store const data = await fetcher() this.cache.set(key, { data, timestamp: Date.now(), staleTime: options.staleTime }) return data } }
SWR provides instant perceived performance by serving cached data while silently refreshing. This pattern is the foundation of libraries like React Query and SWR.
Optimistic Updates with Rollback
// Optimistic update pattern function useOptimisticUpdate<T>( mutationFn: (data: T) => Promise<T>, options: { onMutate: (data: T) => { previousData: T } onError: (error: Error, data: T, context: { previousData: T }) => void onSettled: () => void } ) { return async (newData: T) => { // 1. Snapshot previous state const context = options.onMutate(newData) try { // 2. Optimistically update UI immediately // 3. Send request to server const result = await mutationFn(newData) return result } catch (error) { // 4. Roll back to previous state on failure options.onError(error as Error, newData, context) throw error } finally { // 5. Refetch to ensure consistency options.onSettled() } } } // Usage: like/unlike a post const toggleLike = useOptimisticUpdate( (post) => api.toggleLike(post.id), { onMutate: (post) => { const prev = queryCache.get(['post', post.id]) queryCache.set(['post', post.id], { ...post, liked: !post.liked }) return { previousData: prev } }, onError: (_err, _post, ctx) => { queryCache.set(['post', _post.id], ctx.previousData) }, onSettled: () => queryCache.invalidate(['posts']), } )
Optimistic updates make the UI feel instant by applying changes before the server responds. The key is maintaining a rollback path for when the server request fails.
Cursor-Based Pagination
// Cursor-based pagination for infinite scroll interface PaginatedResponse<T> { items: T[] nextCursor: string | null hasMore: boolean } function useInfiniteList<T>( fetchPage: (cursor: string | null) => Promise<PaginatedResponse<T>> ) { const [pages, setPages] = useState<T[][]>([]) const [cursor, setCursor] = useState<string | null>(null) const [hasMore, setHasMore] = useState(true) const [isLoading, setIsLoading] = useState(false) const loadMore = useCallback(async () => { if (isLoading || !hasMore) return setIsLoading(true) const response = await fetchPage(cursor) setPages(prev => [...prev, response.items]) setCursor(response.nextCursor) setHasMore(response.hasMore) setIsLoading(false) }, [cursor, hasMore, isLoading, fetchPage]) const allItems = useMemo(() => pages.flat(), [pages]) return { items: allItems, loadMore, hasMore, isLoading } } // Combine with Intersection Observer for auto-loading function InfiniteList({ fetchPage }: { fetchPage: FetchFn }) { const { items, loadMore, hasMore } = useInfiniteList(fetchPage) const sentinelRef = useRef<HTMLDivElement>(null) useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) loadMore() }, { rootMargin: '200px' } ) if (sentinelRef.current) observer.observe(sentinelRef.current) return () => observer.disconnect() }, [loadMore]) return ( <> {items.map(item => <ListItem key={item.id} item={item} />)} {hasMore && <div ref={sentinelRef} />} </> ) }
Cursor-based pagination avoids the offset drift problem where items shift as new data is added. Combined with Intersection Observer, it creates smooth infinite scroll experiences.
Common Mistakes
- Not deduplicating concurrent requests for the same resource
- Using offset-based pagination for frequently-updated lists, causing items to shift or repeat
- Forgetting to invalidate related caches after a mutation
- Not showing loading/error states during background revalidation
- Caching data without a TTL, serving arbitrarily stale content
Interview Tips
- Always discuss caching strategy when the interviewer mentions data fetching
- Mention request waterfall vs parallel fetching as a key optimization
- Explain the trade-off between data freshness and perceived performance
- Discuss how React Suspense changes data fetching architecture
- Know the difference between server state (remote) and client state (local UI)