Skip to main content
Frontend for Backend Engineers

Caching and Revalidation

Ravinder··6 min read
FrontendReactTypeScriptCachingTanStack QueryPerformance
Share:
Caching and Revalidation

Backend engineers have deep familiarity with caching. Redis TTLs, CDN cache-control headers, database query caches — the patterns are second nature. Frontend caching applies the same principles but across a different stack: the browser's in-memory query cache, HTTP response headers, and the service worker layer.

The failure mode for backend engineers writing frontend is either no caching (every component mounts and fires a fresh fetch) or naive caching (stale data that never updates). Both are solved by understanding the caching primitives available at each layer.

The Three Layers of Frontend Caching

graph TD A[Component makes a data request] A --> B{TanStack Query cache} B -->|Cache hit, fresh| C[Return cached data immediately] B -->|Cache hit, stale| D[Return stale data + revalidate in background] B -->|Cache miss| E[Issue HTTP request] E --> F{Browser HTTP cache} F -->|Cache-Control: max-age hit| G[Return HTTP cached response] F -->|ETag match| H[304 Not Modified — reuse cached body] F -->|Cache miss| I[Network request to server] I --> J[Server response] J --> K[Store in HTTP cache] K --> L[Store in TanStack Query cache] L --> M[Component receives data]

TanStack Query: The In-Process Cache

TanStack Query maintains a client-side key-value store of query results. The core settings are staleTime and gcTime (formerly cacheTime):

  • staleTime: how long data is considered fresh. During this window, no refetch happens even if the component remounts. Default: 0 (immediately stale).
  • gcTime: how long unused cache entries survive before garbage collection. Default: 5 minutes.
import { useQuery, QueryClient, QueryClientProvider } from "@tanstack/react-query";
 
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,        // 1 minute — most data is stable for at least this long
      gcTime: 5 * 60 * 1000,       // 5 minutes — keep in memory for background refetch
      retry: 3,
      refetchOnWindowFocus: true,  // refetch when user returns to the tab
    },
  },
});
 
// Tuning per query
function useOrderHistory(userId: string) {
  return useQuery({
    queryKey: ["orders", userId],
    queryFn: () => fetchOrders(userId),
    staleTime: 10 * 60 * 1000,  // order history changes rarely — 10 minute freshness
    refetchOnWindowFocus: false,
  });
}
 
// High-frequency data — always fresh
function useLivePrice(symbol: string) {
  return useQuery({
    queryKey: ["price", symbol],
    queryFn: () => fetchPrice(symbol),
    staleTime: 0,
    refetchInterval: 5000, // poll every 5 seconds
  });
}

Stale-While-Revalidate in Practice

The stale-while-revalidate pattern shows cached (potentially stale) data immediately while fetching fresh data in the background. Users see content instantly; the UI silently updates when fresh data arrives.

function ProductList({ category }: { category: string }) {
  const { data, isFetching } = useQuery({
    queryKey: ["products", category],
    queryFn: () => fetchProducts(category),
    staleTime: 30 * 1000,
  });
 
  return (
    <div>
      {/* Show stale indicator without blocking the UI */}
      {isFetching && <span className="text-muted">Refreshing…</span>}
      {data?.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

The subtle difference between isLoading and isFetching:

  • isLoading: true only on the initial load when there is no cached data.
  • isFetching: true whenever a request is in flight, including background revalidations.

Show a full loading state on isLoading. Show a subtle indicator on isFetching.

Cache Invalidation and Optimistic Updates

Cache invalidation in TanStack Query maps directly to cache eviction in Redis:

const queryClient = useQueryClient();
 
// Invalidate after a mutation — equivalent to DEL cache_key
const createPost = useMutation({
  mutationFn: (post: NewPost) => fetch("/api/posts", { method: "POST", body: JSON.stringify(post) }).then(r => r.json()),
  onSuccess: () => {
    // Invalidate all queries whose key starts with ["posts"]
    queryClient.invalidateQueries({ queryKey: ["posts"] });
  },
});
 
// Optimistic update — update cache before server confirms
const likePost = useMutation({
  mutationFn: (postId: string) => fetch(`/api/posts/${postId}/like`, { method: "POST" }),
  onMutate: async (postId) => {
    await queryClient.cancelQueries({ queryKey: ["post", postId] });
    const previous = queryClient.getQueryData<Post>(["post", postId]);
    queryClient.setQueryData<Post>(["post", postId], old => ({
      ...old!,
      likeCount: old!.likeCount + 1,
      likedByMe: true,
    }));
    return { previous }; // context for rollback
  },
  onError: (_err, postId, context) => {
    // Roll back on failure
    queryClient.setQueryData(["post", postId], context?.previous);
  },
  onSettled: (_, __, postId) => {
    queryClient.invalidateQueries({ queryKey: ["post", postId] });
  },
});

HTTP Cache Headers: The Layer Below

TanStack Query caches in memory — it disappears on page reload. HTTP caching persists in the browser's disk cache across sessions. Use both:

# For API responses with versioned data
Cache-Control: private, max-age=300, stale-while-revalidate=60
 
# For user-specific data that must not be shared
Cache-Control: private, no-store
 
# For static assets with content hashing
Cache-Control: public, max-age=31536000, immutable

ETags enable conditional requests — the browser sends If-None-Match, the server returns 304 if unchanged, saving bandwidth without sacrificing freshness:

// Client-side: the browser handles ETags automatically for cached responses
// Server-side (Express example)
app.get("/api/products", async (req, res) => {
  const products = await fetchProducts();
  const etag = `"${hashProducts(products)}"`;
 
  if (req.headers["if-none-match"] === etag) {
    return res.status(304).end();
  }
 
  res.setHeader("ETag", etag);
  res.setHeader("Cache-Control", "private, max-age=60, stale-while-revalidate=300");
  res.json(products);
});

Prefetching and Hydration

Prefetch data before it is needed — on hover, on route change, or server-side during SSR:

// Prefetch on hover — data is ready when user clicks
function PostLink({ postId }: { postId: string }) {
  const queryClient = useQueryClient();
 
  return (
    <a
      href={`/posts/${postId}`}
      onMouseEnter={() => {
        queryClient.prefetchQuery({
          queryKey: ["post", postId],
          queryFn: () => fetchPost(postId),
          staleTime: 10 * 1000,
        });
      }}
    >
      Read post
    </a>
  );
}
 
// SSR hydration (Next.js): dehydrate server-fetched data into the HTML
// Server component
async function PostPage({ params }: { params: { id: string } }) {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({
    queryKey: ["post", params.id],
    queryFn: () => fetchPost(params.id),
  });
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostDetail postId={params.id} />
    </HydrationBoundary>
  );
}

Key Takeaways

  • Frontend caching has three layers: in-process query cache (TanStack Query), HTTP cache (browser disk), and CDN cache — tune each appropriately.
  • staleTime determines freshness; set it to the natural update frequency of your data rather than leaving it at the default of 0.
  • Use isLoading for initial empty-state skeletons and isFetching for subtle background refresh indicators — never block the UI on revalidation.
  • Cache invalidation after mutations maps directly to Redis cache eviction: call invalidateQueries with the affected key prefix.
  • Optimistic updates improve perceived performance — always implement rollback via onError and re-sync via onSettled.
  • ETags and stale-while-revalidate on HTTP responses complement the query cache for cross-session persistence without sacrificing freshness.
Share: