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
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, immutableETags 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.
staleTimedetermines freshness; set it to the natural update frequency of your data rather than leaving it at the default of 0.- Use
isLoadingfor initial empty-state skeletons andisFetchingfor subtle background refresh indicators — never block the UI on revalidation. - Cache invalidation after mutations maps directly to Redis cache eviction: call
invalidateQuerieswith the affected key prefix. - Optimistic updates improve perceived performance — always implement rollback via
onErrorand re-sync viaonSettled. - ETags and
stale-while-revalidateon HTTP responses complement the query cache for cross-session persistence without sacrificing freshness.