Prefetching, Preloading, and Navigation Performance
Learn React Client-Server Communication - Part 038
Prefetching, preloading, resource hints, route data preloading, speculative navigation, cache warming, waterfall elimination, and production navigation performance for React client-server communication.
Part 038 — Prefetching, Preloading, and Navigation Performance
Target mental model: navigation performance is not one request becoming faster. It is a dependency graph starting earlier, doing less unnecessary work, and avoiding serial waits.
Part 037 treated progressive enhancement as resilience architecture.
This part treats prefetching and preloading as latency-shaping tools.
The mistake is to see prefetch as magic:
make page faster by fetching before user clicks
That is only the surface.
The deeper model:
A navigation is a graph of resources.
Some are required now.
Some are likely required soon.
Some are expensive to discover late.
Some are unsafe to fetch early.
Some should never be prefetched.
React engineers must reason about:
- route module loading,
- loader/API data,
- images and fonts,
- connection setup,
- cache policy,
- auth/tenant scope,
- user intent prediction,
- bandwidth contention,
- privacy leakage,
- stale data risk.
Prefetching is not always good.
Bad prefetching competes with critical work, burns mobile data, warms the wrong cache, leaks intent, and increases backend load.
Good prefetching removes waterfalls without lying about freshness.
1. Navigation as a Dependency Graph
A route transition is not a single operation.
A slow navigation may be slow because:
- route JS is discovered after click,
- loader data starts after route JS loads,
- API requires another dependent API call,
- image URL is discovered after data arrives,
- font is discovered after CSS arrives,
- connection setup begins too late,
- authentication refresh blocks all requests,
- cache key changed accidentally,
- CDN cannot reuse cached response,
- hydration blocks interactivity.
Prefetching and preloading attack discovery time and serial dependency.
2. Terminology: Preconnect, DNS Prefetch, Preload, Prefetch
These terms are often mixed.
They are not the same.
| Mechanism | Goal | When to use |
|---|---|---|
dns-prefetch | resolve domain early | low-cost hint for possible third-party origin |
preconnect | open connection early | high-confidence origin soon needed |
preload | fetch required current-page resource early | critical resource needed very soon |
prefetch | fetch likely future navigation resource | next page likely needed |
| route prefetch | fetch route module/data before navigation | app-level next-route prediction |
| query prefetch | warm server-state cache | likely data needed soon |
| image preload | prioritize hero/critical image | image needed above fold |
Mental distinction:
preconnect warms the pipe
preload fetches now-needed resources
prefetch fetches future-likely resources
query/route prefetch warms application caches
3. Resource Hint Layer
Browser resource hints are declarative instructions in HTML.
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://cdn.example.com" />
<link rel="preload" as="font" href="/fonts/inter.woff2" type="font/woff2" crossorigin />
<link rel="prefetch" href="/assets/routes-case-detail.js" />
These hints work below React.
They can improve performance even before application JavaScript runs.
But each has different risk.
preconnect
Use when you are highly confident the origin is needed.
<link rel="preconnect" href="https://api.example.com" crossorigin />
This can reduce DNS/TCP/TLS/QUIC setup cost.
But too many preconnects waste sockets and compete for resources.
preload
Use for resources needed on the current page that would otherwise be discovered late.
<link
rel="preload"
as="image"
href="/hero/case-dashboard.avif"
fetchpriority="high"
/>
Bad preload is worse than no preload.
If you preload a non-critical resource, you may steal bandwidth from the real critical path.
prefetch
Use for likely future navigation resources.
<link rel="prefetch" href="/reports/monthly" />
But be careful:
- user may never navigate there,
- data may go stale,
- prefetch may reveal user intent,
- authenticated responses may be unsafe to cache broadly,
- backend load may increase.
4. Application-Level Prefetch Layer
React apps have additional caches above the browser:
route module cache
loader data cache
query cache
GraphQL normalized cache
image cache
service worker cache
CDN/browser HTTP cache
A browser link rel="prefetch" may warm one layer.
A router/query prefetch may warm another.
Do not assume they are equivalent.
The right strategy depends on what the next screen needs.
5. React Router Link Prefetch
React Router supports link-level prefetch behavior.
Conceptually:
<Link to="/cases/123" prefetch="intent">
Open case
</Link>
The common modes are:
none → do not prefetch
intent → prefetch when user shows intent, such as hover/focus
default/render → prefetch when rendered, depending on router mode/version
viewport → prefetch when link enters viewport, useful for mobile lists
Use intent-based prefetch for links where hover/focus is a strong signal.
Use viewport prefetch cautiously for feeds/lists.
Use render prefetch only when the next route is extremely likely and cheap.
Bad:
{cases.map((caseItem) => (
<Link
key={caseItem.id}
to={`/cases/${caseItem.id}`}
prefetch="render"
>
{caseItem.title}
</Link>
))}
This can prefetch hundreds of detail pages.
Better:
{cases.map((caseItem) => (
<Link
key={caseItem.id}
to={`/cases/${caseItem.id}`}
prefetch="intent"
>
{caseItem.title}
</Link>
))}
For mobile where hover does not exist, use viewport selectively:
<Link to={`/cases/${featuredCase.id}`} prefetch="viewport">
Continue latest case
</Link>
Do not apply viewport prefetch to every row in an infinite list.
6. Query Prefetch with TanStack Query
Application data prefetch is explicit cache warming.
const caseKeys = {
detail: (id: string) => ["case", "detail", id] as const,
};
function usePrefetchCase(id: string) {
const queryClient = useQueryClient();
return () => {
queryClient.prefetchQuery({
queryKey: caseKeys.detail(id),
queryFn: ({ signal }) => api.getCase(id, { signal }),
staleTime: 30_000,
});
};
}
Use it on intent:
function CaseLink({ id, title }: { id: string; title: string }) {
const prefetch = usePrefetchCase(id);
return (
<Link
to={`/cases/${id}`}
onMouseEnter={prefetch}
onFocus={prefetch}
>
{title}
</Link>
);
}
The detail page then reads from the same key:
function CaseDetail({ id }: { id: string }) {
const query = useQuery({
queryKey: caseKeys.detail(id),
queryFn: ({ signal }) => api.getCase(id, { signal }),
staleTime: 30_000,
});
return <CaseView data={query.data} />;
}
The invariant:
Prefetch key and render key must be identical.
If they differ, you warmed the wrong cache.
7. Loader Prefetch vs Query Prefetch
A route loader prefetch is better when:
- data is owned by the route,
- navigation must block on it,
- SSR/hydration should receive it,
- errors/redirects are route-level,
- the URL fully defines input.
A query prefetch is better when:
- data is shared across screens,
- cache reuse matters outside one route,
- background refetch policy matters,
- component composition owns data fragments,
- mutation invalidation targets query keys.
Decision table:
| Need | Prefer |
|---|---|
| route redirect if not found | loader |
| authz redirect | loader |
| shared entity cache | query |
| detail page above-fold required data | loader or query with SSR bridge |
| hover warm entity card | query prefetch |
| next route module + data | router prefetch |
| infinite list next page | query prefetch/infinite query |
Do not duplicate fetching in both layers without a bridge.
Bad:
Router loader fetches /cases/123.
Component query fetches /cases/123 again with different cache key.
Better:
Loader ensures route decision.
Query cache is hydrated/seeded with loader data.
Component uses same query identity.
8. Seed Query Cache from Loader Data
Example pattern:
export async function loader({ params }: LoaderFunctionArgs) {
const caseRecord = await api.getCase(params.caseId!);
return data({ caseRecord });
}
Route:
function CaseRoute() {
const { caseRecord } = useLoaderData<typeof loader>();
const queryClient = useQueryClient();
React.useEffect(() => {
queryClient.setQueryData(caseKeys.detail(caseRecord.id), caseRecord);
}, [queryClient, caseRecord]);
return <CaseDetail id={caseRecord.id} initial={caseRecord} />;
}
Then query:
function CaseDetail({ id, initial }: { id: string; initial: CaseRecord }) {
const query = useQuery({
queryKey: caseKeys.detail(id),
queryFn: ({ signal }) => api.getCase(id, { signal }),
initialData: initial,
staleTime: 15_000,
});
return <CaseView data={query.data} />;
}
This avoids double fetch while preserving query cache behavior.
But beware:
initialData has a timestamp policy.
If it is already old, mark it stale or refetch.
9. Prefetch Budget
Prefetching consumes resources.
Create a budget.
type PrefetchPolicy = {
maxConcurrent: number;
maxPerScreen: number;
maxBytesHint?: number;
minConfidence: "low" | "medium" | "high";
allowOnMobileData: boolean;
requireUserIntent: boolean;
};
Example policy:
const defaultPrefetchPolicy: PrefetchPolicy = {
maxConcurrent: 2,
maxPerScreen: 5,
minConfidence: "medium",
allowOnMobileData: false,
requireUserIntent: true,
};
Check effective connection when available:
function shouldPrefetch() {
const connection = (navigator as any).connection;
if (connection?.saveData) return false;
if (["slow-2g", "2g"].includes(connection?.effectiveType)) return false;
return true;
}
This API is not universal, so treat it as a hint.
Do not make correctness depend on it.
10. Intent Signals
Not all signals are equal.
| Signal | Confidence | Notes |
|---|---|---|
| hover | medium | desktop only, can be accidental |
| focus | medium/high | keyboard intent |
| pointer down | high | very late but useful |
| viewport | low/medium | useful for small curated lists |
| typed search result highlight | high | user likely to open |
| recent history | medium | may reflect backtracking |
| explicit “next” button | high | safe candidate |
| route render | low/high depending context | dangerous in large lists |
A good strategy uses different prefetch depth per signal.
viewport → prefetch route module only
hover/focus → prefetch route module + lightweight data
pointerdown → start high-priority data immediately
explicit next → prefetch full next page
11. Multi-Stage Prefetch
For heavy screens, use staged prefetch.
This avoids spending full data cost for weak signals.
Example:
type PrefetchDepth = "module" | "summary" | "full";
function prefetchCase(id: string, depth: PrefetchDepth) {
if (depth === "summary") {
return queryClient.prefetchQuery({
queryKey: caseKeys.summary(id),
queryFn: ({ signal }) => api.getCaseSummary(id, { signal }),
staleTime: 60_000,
});
}
if (depth === "full") {
return queryClient.prefetchQuery({
queryKey: caseKeys.detail(id),
queryFn: ({ signal }) => api.getCase(id, { signal }),
staleTime: 15_000,
});
}
}
12. Avoiding Prefetch Stampedes
Large lists can create stampedes.
Bad:
{items.map((item) => (
<CaseCard
key={item.id}
onVisible={() => prefetchCase(item.id)}
/>
))}
If 50 cards enter viewport, 50 prefetches start.
Control concurrency:
class PrefetchQueue {
private running = 0;
private queue: Array<() => Promise<void>> = [];
constructor(private readonly maxConcurrent: number) {}
add(task: () => Promise<void>) {
this.queue.push(task);
void this.drain();
}
private async drain() {
if (this.running >= this.maxConcurrent) return;
const task = this.queue.shift();
if (!task) return;
this.running += 1;
try {
await task();
} finally {
this.running -= 1;
void this.drain();
}
}
}
Use dedupe:
const prefetched = new Set<string>();
function prefetchOnce(key: string, task: () => Promise<void>) {
if (prefetched.has(key)) return;
prefetched.add(key);
prefetchQueue.add(task);
}
But remember: query libraries usually dedupe by query key already.
Your queue is for policy and bandwidth control.
13. Prefetch Cancellation
Prefetch should often be cancellable.
If the user's intent disappears, cancel weak prefetch work.
function createIntentPrefetch<T>(fn: (signal: AbortSignal) => Promise<T>) {
let controller: AbortController | null = null;
return {
start() {
controller?.abort();
controller = new AbortController();
return fn(controller.signal).catch((error) => {
if (error instanceof DOMException && error.name === "AbortError") return;
throw error;
});
},
cancel() {
controller?.abort();
controller = null;
},
};
}
Use with hover:
function PrefetchingLink({ to, id, children }: Props) {
const prefetch = React.useMemo(
() =>
createIntentPrefetch((signal) =>
api.getCase(id, { signal }).then((data) => {
queryClient.setQueryData(caseKeys.detail(id), data);
}),
),
[id],
);
return (
<Link
to={to}
onMouseEnter={() => void prefetch.start()}
onMouseLeave={() => prefetch.cancel()}
onFocus={() => void prefetch.start()}
>
{children}
</Link>
);
}
Caution:
Canceling client prefetch does not guarantee the server stopped work.
For GET reads this is usually acceptable.
For writes, do not pre-execute mutation.
14. Never Prefetch Unsafe Mutations
Do not prefetch mutation endpoints.
This sounds obvious but appears in real systems as:
GET /email/send-preview?actuallySends=true
GET /case/123/mark-read
GET /audit/export/start
A prefetcher, crawler, browser optimization, link scanner, or security product may trigger GET requests.
GET must be safe.
If visiting a URL mutates server state, prefetch becomes dangerous.
Correct split:
GET /cases/123/delete-confirmation → safe read
POST /cases/123/delete → mutation command
Then prefetch the confirmation page if appropriate.
Never pre-execute the command.
15. Cache Freshness and Prefetch Staleness
Prefetched data can be stale by the time user navigates.
Policy options:
| Policy | Behavior | Use case |
|---|---|---|
| fresh window | use prefetched data for N seconds | detail pages, moderate freshness |
| stale-while-revalidate | show prefetched data, refetch in background | dashboards/cards |
| navigation-blocking revalidate | verify before render | security-sensitive workflow |
| no data prefetch | only prefetch code | highly volatile/secure data |
Example:
queryClient.prefetchQuery({
queryKey: caseKeys.detail(id),
queryFn: ({ signal }) => api.getCase(id, { signal }),
staleTime: 10_000,
});
This means:
If user navigates within 10 seconds, use data as fresh.
After that, it becomes stale according to query policy.
Do not set long stale windows because prefetch “feels fast”.
Correctness still matters.
16. Security and Privacy of Prefetch
Prefetch can leak information.
Examples:
- prefetching
/cases/high-risk-investigationreveals interest in logs, - third-party origin preconnect reveals potential next action,
- search result prefetch leaks highlighted sensitive entity,
- shared device cache stores sensitive resources,
- browser extension observes prefetched URLs,
- backend analytics receives events for pages never opened.
Guidelines:
Do not prefetch highly sensitive routes by default.
Avoid third-party preconnect unless needed.
Do not include secrets in URLs.
Do not treat prefetch as user view.
Separate analytics: prefetched ≠ visited.
Respect Save-Data and low-bandwidth hints.
For sensitive apps, prefetch route code but not sensitive data.
module prefetch: okay
entity detail data prefetch: maybe not
mutation prefetch: never
17. Prefetch and Authorization Scope
Cache identity must include authorization-relevant scope.
Bad:
["case", id]
if different tenants/users can see different representations.
Better:
["tenant", tenantId, "user", userId, "case", id]
Or at least clear cache on tenant/user switch.
Prefetch makes auth leakage easier because it fetches data before explicit navigation.
Rules:
- never reuse prefetched authenticated data across user/tenant boundaries,
- clear query cache on logout,
- avoid persistent cache for sensitive prefetched data,
- revalidate sensitive data on focus/resume,
- ensure server enforces authorization on every request.
Client cache partitioning is a safety layer.
Server authorization is still the boundary.
18. Preloading Critical Images
Images often dominate perceived navigation performance.
If the next route has a critical hero image, data prefetch alone may not help.
You may need to warm the image too.
function preloadImage(src: string) {
const link = document.createElement("link");
link.rel = "preload";
link.as = "image";
link.href = src;
document.head.appendChild(link);
}
But dynamic image preload should be controlled.
Bad:
items.forEach((item) => preloadImage(item.largeImageUrl));
Better:
preload only likely next hero image
use responsive image metadata
avoid preloading below-the-fold images
use correct dimensions to prevent layout shift
For current-page critical image, prefer server-rendered <link rel="preload" as="image"> in the document head.
19. Font Preloading
Fonts can block text rendering or cause layout shift.
For critical fonts:
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>
But do not preload every font weight.
Rules:
- preload only critical font files,
- use
font-displayintentionally, - avoid excessive weights/styles,
- include
crossoriginwhen required, - measure layout shift and render delay.
A bad font preload can delay more important resources.
20. Connection Warming for API Origins
If your API is on a different origin:
<link rel="preconnect" href="https://api.example.com" crossorigin />
This can reduce first API request latency.
But it only helps if:
- origin is actually needed soon,
- browser honors it,
- connection is not already open,
- the cost of warming does not compete with critical resources.
For many apps, same-origin API via reverse proxy simplifies this:
https://app.example.com/api/*
Benefits:
- fewer CORS issues,
- shared connection,
- simpler cookie policy,
- easier CDN/proxy control.
But same-origin is not automatically faster if backend routing/proxy is poorly configured.
Measure.
21. Waterfall Elimination Patterns
Pattern 1 — Move data requirement to route boundary
Bad:
render route shell → render child → child fetches data
Better:
route matched → loader fetches data → render with data
Pattern 2 — Parallelize independent requests
Bad:
const profile = await getProfile();
const permissions = await getPermissions();
const notifications = await getNotifications();
Better:
const [profile, permissions, notifications] = await Promise.all([
getProfile(),
getPermissions(),
getNotifications(),
]);
Pattern 3 — Collapse frontend N+1
Bad:
GET /cases
GET /cases/1/summary
GET /cases/2/summary
GET /cases/3/summary
...
Better:
GET /cases?include=summary
or:
POST /case-summaries/batch
Pattern 4 — Prefetch next page cursor
function usePrefetchNextPage(query: InfiniteData<CasePage> | undefined) {
const queryClient = useQueryClient();
const nextCursor = query?.pages.at(-1)?.nextCursor;
React.useEffect(() => {
if (!nextCursor) return;
void queryClient.prefetchQuery({
queryKey: caseKeys.page(nextCursor),
queryFn: ({ signal }) => api.getCases({ cursor: nextCursor, signal }),
staleTime: 20_000,
});
}, [queryClient, nextCursor]);
}
Use this only when the user is likely to continue.
22. Prefetching Search Results
Search pages are tempting to over-prefetch.
Bad:
prefetch detail for every result on every keystroke
Better:
prefetch only focused/highlighted result
prefetch after debounce
cancel on query change
limit concurrency
use short staleTime
Example:
function SearchResult({ result, active }: Props) {
const queryClient = useQueryClient();
React.useEffect(() => {
if (!active) return;
const controller = new AbortController();
void queryClient.prefetchQuery({
queryKey: caseKeys.detail(result.id),
queryFn: () => api.getCase(result.id, { signal: controller.signal }),
staleTime: 10_000,
});
return () => controller.abort();
}, [active, result.id, queryClient]);
return <Link to={`/cases/${result.id}`}>{result.title}</Link>;
}
Search prefetch policy should be conservative because search intent changes quickly.
23. Measuring Navigation Performance
Do not rely on “feels faster”.
Measure:
navigation start
route match time
route module loaded
loader/request started
first byte received
data parsed
render committed
largest content rendered
interactive controls ready
Client marks:
performance.mark("case-detail:navigation-start");
await prefetchCase(id);
performance.mark("case-detail:data-ready");
performance.measure(
"case-detail:data-prefetch-duration",
"case-detail:navigation-start",
"case-detail:data-ready",
);
Use PerformanceObserver for resource timing:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntriesByType("resource")) {
if (entry.name.includes("/api/cases/")) {
console.log({
name: entry.name,
duration: entry.duration,
transferSize: (entry as PerformanceResourceTiming).transferSize,
});
}
}
});
observer.observe({ entryTypes: ["resource"] });
In production, sample and send aggregate metrics.
Do not log sensitive URLs or query strings carelessly.
24. Prefetch Telemetry
Track whether prefetch helps.
type PrefetchEvent = {
route: string;
resource: string;
trigger: "hover" | "focus" | "viewport" | "render" | "explicit-next";
startedAt: number;
completed: boolean;
used: boolean;
cancelled: boolean;
bytes?: number;
ageWhenUsedMs?: number;
};
Important metrics:
| Metric | Meaning |
|---|---|
| prefetch hit rate | prefetched resource later used |
| prefetch waste rate | prefetched resource never used |
| age when used | staleness risk |
| bytes wasted | bandwidth cost |
| backend prefetch load | server cost |
| navigation delta | actual user benefit |
| cancellation rate | weak intent signal quality |
If prefetch hit rate is low and bytes are high, remove or narrow it.
25. Prefetch and Backend Load
Frontend prefetch is backend traffic.
Backend teams may see:
more GET requests
lower cache hit ratio
higher p95 under traffic peaks
analytics noise
rate limit pressure
DB read amplification
Coordinate policies:
- use cacheable endpoints,
- shape prefetch traffic with concurrency limits,
- use CDN for public/static resources,
- mark prefetch requests if useful,
- do not prefetch high-cost reports,
- avoid prefetch under high server load,
- use summary endpoints for weak signals.
Optional request header:
Purpose: prefetch
or modern equivalents where supported by platform/proxies.
But do not rely on custom headers if they trigger CORS preflight unnecessarily.
For same-origin app APIs, custom headers are simpler.
For cross-origin APIs, be careful.
26. HTTP Cache-Friendly Prefetch
Prefetch is more useful when HTTP caching is well-designed.
For stable public resources:
Cache-Control: public, max-age=31536000, immutable
For user-specific moderately fresh reads:
Cache-Control: private, max-age=30
ETag: "case-123-v42"
For sensitive non-cacheable data:
Cache-Control: no-store
For revalidation:
ETag: "abc"
If-None-Match: "abc"
304 Not Modified
Application cache and HTTP cache should not fight.
If query cache says “fresh for 60 seconds” but HTTP response says “no-store because sensitive”, ask why.
Maybe query memory cache is acceptable but persistent/browser cache is not.
Maybe both should be short.
Document the boundary.
27. Route Module vs Data Prefetch
Sometimes only route module prefetch is safe.
Example:
/cases/:id contains sensitive details.
Policy:
prefetch JS module on intent
fetch sensitive data only after explicit navigation
This still helps because route code is ready when user clicks.
For less sensitive data:
prefetch module + summary data on hover
fetch full data on navigation
For highly likely safe next page:
prefetch module + full data + critical image
Use increasing depth with increasing confidence.
28. Predictive Prefetching
Predictive prefetching uses behavior patterns.
Examples:
after list page, user often opens first case
after step 1, user usually goes to step 2
after saving draft, user often previews
Be careful.
Prediction creates hidden product behavior.
It should be:
- measurable,
- bounded,
- privacy-reviewed,
- disabled on constrained networks,
- cheap to remove,
- safe under wrong predictions.
A good predictive prefetch target:
high probability
low cost
safe content
high latency saving
cacheable
A bad target:
low probability
large payload
sensitive data
non-cacheable
expensive DB query
29. Preloading in Server-Rendered React
Server-rendered React can emit preload hints early.
The value is discovery speed.
If the server knows the critical resources before the client does, it should tell the browser early.
Example head output:
<head>
<link rel="preconnect" href="https://api.example.com" crossorigin />
<link rel="preload" href="/assets/case-route.js" as="script" />
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />
</head>
But modern bundlers/frameworks usually manage module preload.
Do not manually fight the framework unless measurement shows a gap.
Manual hints should be intentional and audited.
30. HTTP/2, HTTP/3, and Multiplexing Reality
HTTP/2 and HTTP/3 reduce some costs of multiple requests.
They do not remove all costs.
Still expensive:
- request scheduling,
- server CPU,
- database work,
- response parsing,
- main-thread render,
- cache fragmentation,
- authorization checks,
- backend fan-out,
- mobile radio/battery cost.
Do not use multiplexing as an excuse for frontend N+1.
A hundred small requests can still be worse than one well-designed aggregate response.
The right abstraction is not:
fewer requests always
It is:
minimize critical path and total waste while preserving cacheability and ownership
31. Production Decision Matrix
| Situation | Strategy |
|---|---|
| User hovers detail link | route/data prefetch with short stale window |
| Mobile list with top 3 likely items | viewport prefetch summary only |
| Wizard next step | prefetch next route/module/data after current step valid |
| Large report | do not prefetch; show explicit prepare/load |
| Sensitive case detail | prefetch module only, data on navigation |
| Public docs page | aggressive prefetch okay |
| Save-data enabled | disable speculative prefetch |
| Slow connection | preconnect only or no prefetch |
| Search result active item | cancelable short-lived prefetch |
| Infinite list next cursor | prefetch next page near bottom |
| High backend load | reduce/disable prefetch remotely |
A mature system can make prefetch policy remotely configurable.
32. Feature Flagging Prefetch
Prefetch should be easy to turn off.
type RemotePerfConfig = {
prefetchEnabled: boolean;
routePrefetchMode: "none" | "intent" | "viewport";
queryPrefetchEnabled: boolean;
maxConcurrentPrefetches: number;
};
Usage:
function SmartLink({ to, children }: { to: string; children: React.ReactNode }) {
const config = usePerfConfig();
return (
<Link
to={to}
prefetch={config.prefetchEnabled ? config.routePrefetchMode : "none"}
>
{children}
</Link>
);
}
Why:
- backend incident mitigation,
- bad release rollback,
- mobile traffic protection,
- experiment measurement,
- tenant-specific policy.
Treat performance behavior as operable configuration.
33. Testing Prefetch Behavior
Test not only that prefetch happens.
Test that it does not happen when unsafe.
Test cases:
| Scenario | Expectation |
|---|---|
| hover case link | detail prefetch starts |
| mouse leaves quickly | weak prefetch cancels if policy says so |
| Save-Data enabled | prefetch disabled |
| tenant switch | old prefetched data not visible |
| sensitive route | module prefetch only |
| infinite list viewport | only bounded number prefetched |
| mutation endpoint | never prefetched |
| prefetched data stale | revalidation happens |
| backend 429 | prefetch backs off |
| chunk failure | reload/update path available |
Example with route mocking:
test("does not prefetch sensitive data on link hover", async ({ page }) => {
const requests: string[] = [];
page.on("request", (request) => {
requests.push(request.url());
});
await page.goto("/cases");
await page.getByRole("link", { name: /high risk case/i }).hover();
expect(requests.some((url) => url.includes("/api/cases/high-risk"))).toBe(false);
});
34. Debugging Navigation Slowness
Use this sequence.
Step 1 — Identify critical path
Which resources must complete before useful render?
Step 2 — Check discovery
Were critical resources discovered late?
Step 3 — Check serialization
Are independent requests happening sequentially?
Step 4 — Check cache identity
Did prefetch warm the same key used by render?
Step 5 — Check stale policy
Was prefetched data considered stale immediately?
Step 6 — Check backend cost
Is the endpoint slow even when started early?
Step 7 — Check render cost
Is data ready but React render/main thread is slow?
Step 8 — Check resource contention
Did prefetch compete with current-page critical resources?
Navigation performance is a system problem.
Do not stop at network timing.
35. Common Smells
Smell 1 — Prefetch everything in sight
<Link prefetch="render" />
inside a 200-row table.
Smell 2 — Prefetch key mismatch
prefetch: ["case", id]
render: ["tenant", tenantId, "case", id]
Smell 3 — Long stale time for volatile data
staleTime: 10 * 60 * 1000
on approval status that can change from another user.
Smell 4 — Sensitive data prefetch
Prefetching data the user has not intentionally opened.
Smell 5 — Mutation hidden behind GET
Unsafe endpoint becomes prefetchable.
Smell 6 — No measurement
Prefetch exists because it “should be faster”, but hit rate and waste are unknown.
Smell 7 — Backend surprise
Frontend silently doubles backend read traffic.
Smell 8 — Manual hints fighting the framework
Duplicate preload/modulepreload causing wasted requests.
36. Implementation Playbook
For a new route:
- draw the navigation dependency graph,
- mark critical resources,
- mark late-discovered resources,
- decide route loader vs query ownership,
- define cache key identity,
- set freshness policy,
- choose prefetch trigger,
- choose prefetch depth,
- define cancellation/backoff,
- restrict by network/user/privacy policy,
- instrument hit/waste/navigation delta,
- add remote kill switch if high-impact.
Example policy:
type RoutePerformancePolicy = {
route: string;
modulePrefetch: "none" | "intent" | "viewport" | "render";
dataPrefetch: "none" | "summary" | "full";
staleTimeMs: number;
sensitive: boolean;
maxConcurrent: number;
};
const caseDetailPolicy: RoutePerformancePolicy = {
route: "/cases/:id",
modulePrefetch: "intent",
dataPrefetch: "summary",
staleTimeMs: 15_000,
sensitive: true,
maxConcurrent: 2,
};
37. Summary
Prefetching and preloading are not decorations.
They are scheduling decisions.
A production React app should distinguish:
- browser resource hints,
- route module prefetch,
- loader/data prefetch,
- query cache warming,
- image/font preload,
- connection warming,
- current-page critical resources,
- future-navigation likely resources.
The strongest invariant:
Start likely critical work earlier, but never make correctness, privacy, or backend stability worse.
Performance work is not only making the next page fast.
It is making the whole system faster under real constraints.
This completes Phase 5.
In the next phase, we cross into React Server Components, Server Functions, SSR, hydration, and streaming.
References
- React Router —
Linkprefetch: https://reactrouter.com/api/components/Link - React Router — Progressive Enhancement: https://reactrouter.com/explanation/progressive-enhancement
- TanStack Query — Prefetching: https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
- TanStack Query — Query Keys: https://tanstack.com/query/latest/docs/framework/react/guides/query-keys
- MDN —
rel="preload": https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/preload - MDN —
rel="prefetch": https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/prefetch - MDN — Resource Timing API: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming
- web.dev — Resource hints: https://web.dev/learn/performance/resource-hints
You just completed lesson 38 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.