Skimming the product grid fires dozens of prefetch GETs
Prefetch on intent rather than on hover, and give it a real staleTime, or the warm cache gets refetched the moment it is used
What you'll build: intent-based prefetch that makes clicks feel instant — without hover-bombing your API — by debouncing prefetchQuery (~150–200ms), cancelling on mouse leave, and setting a real staleTime so the warm cache isn't ignored.
The scenario
A listing uses onMouseEnter={() => queryClient.prefetchQuery(productDetailOptions(id))} on every card. Power users skim 40 cards in two seconds → 40 GETs (many aborted mid-flight, still costly on the edge). Mobile has no hover; the optimization does nothing there, but desktop QA looks "instant." After launch, API dashboards show a prefetch-shaped traffic spike correlated with scroll/hover, not purchases.
A second bug: prefetch omits staleTime (default 0). Click lands on a detail useQuery that treats the warm entry as already stale → immediate refetch, prefetch wasted (prefetch on intent).
Why it escaped QA: slow deliberate hovers in demos; localhost cheap; nobody scrolled quickly across a dense grid with the Network panel open.
Walkthrough
Stage 1 — Name it: prefetch is a bet on intent
Prefetch pays when the user likely navigates. Continuous hover events are not intent — dwell is. Request waterfall already warns against speculative prefetch everywhere; this recipe is the hover-shaped failure.
Stage 2 — Reject raw onMouseEnter prefetch
No debounce → storm. No staleTime → useless warm cache. Prefetching every row in useEffect on mount → worse.
Stage 3 — Debounced intent prefetch
import { useEffect, useRef } from "react";import { useQueryClient } from "@tanstack/react-query";import { productDetailOptions } from "../api/productOptions";import { Link } from "react-router";
export function ProductCardLink({ id, children }: { id: string; children: React.ReactNode }) { const qc = useQueryClient(); const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
function clear() { if (timer.current) clearTimeout(timer.current); timer.current = null; }
useEffect(() => () => clear(), []);
return ( <Link to={`/products/${id}`} onMouseEnter={() => { clear(); timer.current = setTimeout(() => { void qc.prefetchQuery({ ...productDetailOptions(id), staleTime: 60_000, // must be > 0 or click refetches anyway }); }, 175); }} onMouseLeave={clear} onFocus={() => { // keyboard users: prefetch on focus without delay storms void qc.prefetchQuery({ ...productDetailOptions(id), staleTime: 60_000 }); }} > {children} </Link> );}Share productDetailOptions with the detail page so keys/queryFn match.
Stage 4 — Harden + verify the loop
- Touch devices: rely on router/
<Link>viewport prefetch or tap — don't fake hover. - Cap concurrency if needed (simple in-flight Set).
- Prefetch next page of a list after settle (pagination), not every sibling card.
Verify the loop. Skim 20 cards quickly: 0 (or ~0) detail GETs. Hover one card 200ms: 1 GET. Click within staleTime: detail shows with no second GET (or only background if you chose 'always' on detail). Hover leave before 175ms: no GET.
Variations
- Lodash
debounce— same idea; cancel on leave. - IntersectionObserver prefetch — when a card is ~visible, prefetch detail (throttle).
- Router link prefetch — RR/TanStack link prefetch for the route module; still pair with Query prefetch for data.
- Idle callback —
requestIdleCallbackto prefetch the next page only. - SSR
Promise.allprefetch — server parallel warm (nextjs bridge); different layer.
Trade-offs and common pitfalls
- Undebounced hover — storm.
staleTime: 0on prefetch — wasted work.- Mismatched keys vs detail page — cache miss on click.
- Prefetching POST/mutable endpoints — don't.
- Ignoring keyboard — add focus prefetch.
- Mobile hover polyfills — usually wrong.
- Prefetch on every mousemove — worse than enter.
- No cancel on leave — timers fire after skim.
- Prefetch storm + no HTTP/2 — connection queue jank.
- Measuring only click latency — miss the skim cost.
When NOT to prefetch on hover
Sparse UIs with one primary CTA — prefetch on mount/idle of that target instead. Huge payloads (video manifests) — prefetch metadata only. Rate-limited APIs — prefer click-to-fetch. Intent must be cheaper than the miss.
See also
- Prefetch on intent
- Request waterfall
- Lazy route flashes blank — chunk prefetch sibling
- Route splitting
References
- TanStack Query — Prefetching
Demo source
demos/data-fetching/hover-prefetch-request-storm/— skim vs dwell Network traces. (Demo host TBD)