Warming the Cache During App Bootstrap

Every application has a handful of requests that every session makes: the current user, the feature flags, the workspace list. Fetching them when the first component mounts means they start after the bundle has parsed, after React has hydrated, and after the router has matched — several hundred milliseconds of dead time on a mid-range phone during which the network was idle. This page covers starting them earlier without making first paint worse, under Prefetching & Cache Warming. It is the bootstrap-scoped sibling of Prefetching Queries on Hover and Route Change, and it removes the first step of the chains described in Preventing Request Waterfalls With Parallel Queries.

Prerequisites

  • A QueryClient created in a module you can import before rendering.
  • A list of requests that genuinely occur in every session, evidenced rather than assumed.
  • queryOptions objects for those requests, so prefetch and read cannot disagree.
Three tiers, three dispatch points A bootstrap timeline. Critical prefetches are dispatched from the entry module before React mounts. Route prefetches are dispatched when the router matches. Speculative warming is deferred to requestIdleCallback after first paint, and is skipped entirely on a metered connection. What starts when — and what is allowed to be skipped entry module router match first paint idle tier 1 — critical session, flags, workspaces never skipped, never awaited tier 2 — this route whatever the URL implies dispatched in the loader tier 3 — speculative the screen they usually open next idle only, skipped when metered The failure mode: awaiting tier 1 before mounting First paint then waits for the network, which is strictly worse than fetching at mount. Tiers 1 and 2 together should stay within the browser's concurrent-connection budget.
Three tiers with three different rules. Only tier three is allowed to be dropped, and only tier one is unconditional.

Step 1 — Separate Blocking Session Data From Warming

The distinction is whether the application can render anything useful without it. Very little qualifies.

import { queryOptions } from '@tanstack/react-query';

/** Tier 1: needed by the shell itself — the nav cannot render without them. */
export const CRITICAL = [
  queryOptions({ queryKey: ['session', 'me'], queryFn: fetchCurrentUser, staleTime: 5 * 60_000 }),
  queryOptions({ queryKey: ['flags'], queryFn: fetchFeatureFlags, staleTime: 10 * 60_000 }),
  queryOptions({ queryKey: ['workspaces', 'list'], queryFn: fetchWorkspaces, staleTime: 60_000 }),
];

/** Tier 3: likely-next screens. Nice to have; never worth a byte on a metered link. */
export const SPECULATIVE = [
  queryOptions({ queryKey: ['boards', 'list'], queryFn: fetchBoards, staleTime: 30_000 }),
  queryOptions({ queryKey: ['notifications', 'unread'], queryFn: fetchUnread, staleTime: 30_000 }),
];

Cache Behavior Analysis: Declaring these as queryOptions rather than as ad-hoc prefetch arguments guarantees that the prefetch and the eventual useQuery address exactly the same cache entry — a prefetch with a hand-written key that differs by one element warms an entry nothing will ever read, which is the most common way this technique silently does nothing. The staleTime travels with the options, so a warmed entry is still fresh when the component mounts a few hundred milliseconds later and no second request is made. Keep tier 1 genuinely small: each entry competes for the same connections as the JavaScript and CSS the page still needs.


Step 2 — Dispatch Critical Prefetches Before React Mounts

The entry module runs before any component. Firing there, without awaiting, overlaps the network with framework startup.

import { createRoot } from 'react-dom/client';
import { queryClient } from './query-client';
import { CRITICAL } from './warming';

// Dispatched at module evaluation: these requests are on the wire before
// React has been imported, let alone rendered.
for (const options of CRITICAL) {
  // void, not await — the promise is deliberately unobserved here.
  void queryClient.prefetchQuery(options);
}

createRoot(document.getElementById('root')!).render(<App />);

Cache Behavior Analysis: prefetchQuery creates the cache entry and starts the fetch without creating an observer, so nothing renders as a result and no gcTime timer is armed until a component later attaches — meaning a warmed entry the user never needs is collected normally. When the component does mount, its observer attaches to the in-flight promise rather than starting a second request, because deduplication is keyed on the query hash, not on how the fetch was initiated. The rejected-promise case needs care: an unhandled rejection from a prefetch is reported to the console, which is why prefetchQuery — unlike fetchQuery — swallows errors and leaves the entry in error status for the component to handle.

Where the session request starts, before and after warming Timeline comparing two bootstraps. Without warming the session request starts at 480 milliseconds, after bundle parse and hydration. With warming it starts at 40 milliseconds, in the entry module, and has resolved by the time the component mounts. Where the session request starts, before and after warming 0ms HTML parsed entry script begins downloading 40ms Entry module runs warmed: session request dispatched 220ms Session resolves entry already populated 480ms React mounts unwarmed: request starts only now 700ms Unwarmed resolves 480ms of idle network recovered
The request is not faster — it simply stops waiting for work it never depended on.

Step 3 — Defer Speculative Warming to Idle

Tier 3 must never compete with the current screen. requestIdleCallback runs it in the gaps.

export function warmSpeculatively(client: QueryClient, options: typeof SPECULATIVE) {
  const schedule =
    'requestIdleCallback' in window
      ? window.requestIdleCallback
      : (cb: IdleRequestCallback) => window.setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 12 }), 200);

  let index = 0;
  const pump = (deadline: { timeRemaining: () => number }) => {
    // One prefetch per idle slice, and only while there is real time left.
    while (index < options.length && deadline.timeRemaining() > 4) {
      void client.prefetchQuery(options[index]);
      index += 1;
    }
    if (index < options.length) schedule(pump);
  };

  schedule(pump);
}

// Called after first paint, not before it.
requestAnimationFrame(() => requestAnimationFrame(() => warmSpeculatively(queryClient, SPECULATIVE)));

Cache Behavior Analysis: Draining one prefetch per idle slice rather than firing the whole tier at once keeps speculative requests from filling the connection pool ahead of anything the user actually triggers — the browser has no notion of priority between two fetch calls, so spacing them is the only lever available. The double requestAnimationFrame defers past the first paint reliably: the first callback runs before paint, the second after it, which is a more dependable signal than a timeout. Because each prefetch respects staleTime, a speculative warm that lands on an entry already populated by tier 1 costs nothing at all.


Step 4 — Skip Warming the Connection Cannot Afford

Speculative fetching is a bet with the user’s bandwidth. On a metered or slow connection, decline the bet.

interface NetworkInformation { saveData?: boolean; effectiveType?: string }

export function shouldWarmSpeculatively(): boolean {
  const connection = (navigator as Navigator & { connection?: NetworkInformation }).connection;
  if (!connection) return true; // no signal — assume a normal connection

  // The user explicitly asked the browser to conserve data.
  if (connection.saveData) return false;
  // 2g and slow-2g cannot afford speculation; every byte delays what is needed.
  if (connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g') return false;

  // Very low memory devices pay for a large cache in eviction and GC pressure.
  const memory = (navigator as Navigator & { deviceMemory?: number }).deviceMemory;
  if (typeof memory === 'number' && memory <= 2) return false;

  return true;
}

if (shouldWarmSpeculatively()) {
  requestAnimationFrame(() => requestAnimationFrame(() => warmSpeculatively(queryClient, SPECULATIVE)));
}

Cache Behavior Analysis: Skipping tier 3 changes nothing about correctness — every one of those entries would have been fetched on demand anyway, so declining the bet costs a round trip later rather than producing a broken screen. Checking deviceMemory alongside connection quality matters because a warmed entry is a retained entry: on a 2 GB device, speculatively caching several megabytes brings forward the eviction described in Limiting Cache Size With LRU Eviction. Neither API is available everywhere, so both branches must default to permissive rather than blocking on a missing signal.

Time to a fully populated first screen on Fast 3G Bar chart of milliseconds to a populated first screen. No warming takes 940. Warming tier one takes 610. Tier one plus a route loader takes 540. Awaiting tier one before mount takes 1,180. Time to a fully populated first screen on Fast 3G Same bundle, same endpoints, four bootstrap strategies no warming 940 ms tier 1 dispatched, not awaited 610 ms tier 1 + route loader 540 ms tier 1 awaited before mount 1180 ms
The last bar is the trap: awaiting the warm-up makes bootstrap slower than not warming at all.

Edge Cases & Gotchas

Warming before authentication resolves. A prefetch issued before the auth token is available fails with a 401 and caches an error, which the component then renders. Gate tier 1 on token availability, or make the transport wait for the token rather than the cache.

Server-side rendering already warms tier 1. If the server prefetches and dehydrates the session queries, repeating them in the browser entry module duplicates the work. Check getQueryData first, or make bootstrap warming conditional on the absence of a hydrated state.

Stale warming on a returning visit. A persisted cache may already hold tier 1 from the previous session. prefetchQuery respects staleTime, so a still-fresh persisted entry produces no request — provided staleTime is set on the options rather than on the call site.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
First paint regressed after adding warming The prefetch is awaited before rendering Dispatch with void; never block mount on a prefetch
Warming has no measurable effect The prefetch key differs from the key the component reads Share one queryOptions object between prefetch and useQuery
Route data arrives after warmed data it does not need Tier 3 saturated the connection pool ahead of tier 2 Defer tier 3 to requestIdleCallback and drain one at a time
Warmed queries render as errors Prefetch ran before the auth token existed Gate warming on token availability
Memory spikes on low-end devices Speculative warming retains entries the session never uses Skip tier 3 when deviceMemory is low

Frequently Asked Questions

Does warming the cache delay first paint?

Only if you await it, and that is by far the most common way this technique backfires. A prefetch dispatched with void starts the request and returns synchronously, so module evaluation continues and React mounts while bytes are in flight — the network and the main thread work in parallel, which is the entire point. Awaiting the same prefetch inverts it: first paint now waits on a round trip that the old fetch-at-mount approach at least overlapped with rendering. If the measurement shows bootstrap got slower, look for an await before checking anything else.

How much should I prefetch at bootstrap?

Tiers 1 and 2 together should stay within the browser’s concurrent-connection budget — around six on HTTP/1.1, considerably more on HTTP/2 but bounded by your server’s concurrency. Beyond that limit the extra requests do not run in parallel; they queue, and they queue ahead of requests the user triggers, so a generous warm-up actively delays the interaction it was meant to accelerate. Three or four critical entries is a healthy tier 1 for most applications; if your list is longer, that is usually a sign the shell is fetching things it does not need to render.

Should warming respect Save-Data and slow connections?

Yes, for the speculative tier at least. Tier 3 is a bet that the user will open a screen they have not asked for, paid in their bandwidth — and on a metered connection with Save-Data set, the user has explicitly said they do not want that bet taken on their behalf. Skipping it costs nothing but a round trip later, and only if the guess was right. Tier 1 is different: those requests will happen regardless, so warming them saves time without spending anything extra.