Skip to main content

/writing

A Practical Prefetching Strategy with TanStack Query and Router

· 4 min read

A dashboard that feels slow loses trust. People click a link and expect the data to already be there. I built a prefetching strategy that makes a dashboard feel instant even when the API takes a few hundred milliseconds.

It works in two layers: prefetching at the route level in loaders, and prefetching the rows someone is most likely to click.

Route-level prefetching

I moved prefetching from beforeLoad into the loader on every route. The loader calls queryClient.ensureQueryData() with the same query options the page components use, so fetching starts when navigation starts rather than when a component mounts.

The decision that made this work was using the same query options factories in both places. I pulled them into per-domain files so the loader and the component could never disagree about a cache key or a stale time. By the time the page mounted, the data was already there.

I tried defaultPreload: 'intent' for hover-based prefetching at the router level and turned it off. With loaders already running on navigation and most lists being long, hover preload was either redundant for the row we were about to click or wasted on the dozens we were not. The loaders gave the same perceived speed without firing a request every time the pointer moved.

Prefetching what is likely next

For tables I added an intersection observer on the rows. When a row scrolls into view, it kicks off a background prefetchQuery for that row’s detail view, so the data is usually cached before the click.

The observer runs with a small positive rootMargin, so prefetching starts just before a row is fully visible. That buys the network a few hundred milliseconds on a slow connection. The threshold stays at the default, because partial visibility is signal enough. A Set tracks which rows have already been prefetched, so scrolling up and down does not refire anything.

const prefetched = new Set<string>();

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      const id = (entry.target as HTMLElement).dataset.rowId;
      if (!entry.isIntersecting || !id || prefetched.has(id)) continue;
      prefetched.add(id);
      void queryClient.prefetchQuery(invoiceQueries.detail(id));
    }
  },
  { rootMargin: '200px 0px' },
);

This beat prefetching whole pages. Most people do not read a table in order. They scan, scroll, and click the one or two rows that catch their eye, so prefetching exactly those rows is cheaper and more accurate than fetching the next page in either direction. It also fails gracefully: scroll too fast for the observer and the detail view falls back to the same loader fetch everything else uses.

Why it stuck

The reason this outlived the feature it was built for is that it needs no per-route decision. Every route in the dashboard loads the same way, because the loader and the component read the same query-option factory. A new route inherits the behavior by using the factory, and nobody re-argues prefetching page by page.

That is the difference between a technique and a default. A technique gets applied where somebody remembers it. A default applies where nobody thinks about it.

What I turned off

refetchOnWindowFocus, globally. Stale times are already tuned to how often each kind of data changes, so a focus event is the wrong trigger. Data that should refresh will refresh on its own boundary, and data that should not is exactly what people saw reloading every time they came back to the tab. Turning it off removed that jarring full-page refresh.

refetchOnReconnect went for the same reason. The refresh strategy lives in stale times, not in event triggers.

retry is a small fixed number for mutations and zero for queries that come back 4xx. A 401 or a 404 is not a transient failure, and retrying it only delays the error someone needs to see.