Tuning SvelteKit Data Preload Directives

Your app sets data-sveltekit-preload-data="hover" on <body>, the Network panel shows a __data.json request for almost every link the pointer crosses, your server load functions run five to eight times per navigation — and the click still waits on a fresh data round trip.

That combination is the signature of a directive that is firing too often and landing too late. Preloading is not free speculation in SvelteKit: a data preload executes your server load chain, and the router keeps only one preloaded result at a time. Tuning it is therefore not a matter of picking the most aggressive value, but of matching the trigger to two numbers you can measure on the route itself. The directive syntax and the router API are catalogued in the parent guide on SvelteKit resource loading optimization; this page is about choosing the value.

Root cause: one cache slot, and a trigger that fires on crossings

The hover value is not wired to pointerover on each anchor. SvelteKit’s client attaches a throttled pointer-move listener to the app container and arms a short settle timer — roughly 20 ms — before acting. When it fires, the router walks up the DOM from the element under the pointer to the nearest ancestor carrying a data-sveltekit-preload-* attribute, resolves the anchor’s href into a navigation intent, and calls the internal preload. The consequence is that the trigger condition is the pointer being over a link, which a user scanning a 48-row product grid satisfies six or seven times on the way to the row they want.

Each of those crossings calls preloadData, and preloadData is not a cheap cache warm. It imports the route’s modules, then runs the whole load chain for the destination: a GET /<pathname>/__data.json that executes your server load functions against your database, followed by the universal load in the browser. Preloading a link is, from the origin’s point of view, indistinguishable from navigating to it. A route table that multiplies each navigation by 6.4 preloads multiplies that route’s query volume by 6.4 as well, and the added origin latency then makes the round trip you are trying to hide longer.

The second half of the problem is where the result goes. The router holds the preloaded data in a single-entry cache: one record of the intent id and its pending promise. A navigation reuses it only when the intent id of the click matches the record exactly. Every new preloadData overwrites the previous record, so speculative loads across a list are mutually destructive — the seventh hover erases the first six. Code preloads behave in the opposite way, because a dynamically imported module lands in the module map and the HTTP cache and stays there; nothing evicts it. That asymmetry is the single most useful fact for tuning: code preloading accumulates, data preloading does not.

One data preload survives: three hovers, one cache slotThree lanes on a 1200 millisecond timeline. The pointer lane shows hovers over links a, b and c at 40, 380 and 760 milliseconds, then a click on link a at 1120 milliseconds. The network lane shows a 96 millisecond __data.json request for each hover, plus a fourth refetch after the click. The router cache lane shows the single slot holding a, then b, then c — so the click on a is a miss and the user pays the full round trip despite three completed preloads. One data preload survives: hovering three links leaves only the last one warm pointer network cache slot hover /a hover /b hover /c click /a __data.json /a __data.json /b __data.json /c refetch /a empty holds /a holds /b holds /c — unused 0 300 ms 600 ms 900 ms 1,200 ms Each hover overwrites the slot, so only /c is warm when the pointer returns to /a. Three preloads ran three server load chains and saved the user nothing.

Minimal reproduction

A route table and a load function with a timestamp are enough. The counter below prints one line per execution of the server load, so the ratio between those lines and actual navigations is visible in the terminal.

// src/routes/products/[id]/+page.server.js
let runs = 0;

export async function load({ params, fetch }) {
  // Every line printed here is a full load chain the origin ran. A hover
  // preload prints exactly the same line a real navigation does — the server
  // cannot tell them apart, which is why the ratio has to be measured on the
  // client and correlated back to this count.
  console.log(`load #${++runs} for ${params.id} at ${new Date().toISOString()}`);

  const product = await fetch(`/api/products/${params.id}`).then((r) => r.json());
  return { product };
}
<!-- src/routes/products/+page.svelte — the reproduction: 48 anchors under a
     body-level data preload. Moving the pointer diagonally across the grid to
     reach one card crosses six or seven others, and each crossing arms the
     20 ms settle timer, resolves the intent, and runs the load above. -->
<ul>
  {#each products as p (p.id)}
    <li><a href="/products/{p.id}">{p.title}</a></li>
  {/each}
</ul>

Load the page with data-sveltekit-preload-data="hover" on <body>, move the pointer once from the top-left card to a card in the third row, and click. The terminal shows six or seven load #n lines; the Network panel shows six or seven __data.json requests; and if any of those crossings happened after the one for the card you clicked, the click issues an eighth. Nothing is broken — the directive is doing exactly what it says — but the cost model is upside down.

Measure the two numbers that decide the directive

There are only two inputs. The first is the data round trip: how long __data.json takes for this route under field-like conditions. The second is the dwell distribution: how long the pointer actually rests on a link before the click. A preload can only hide latency if dwell exceeds the round trip, and the comparison has to be made at p75, not at the median, because the users who click fastest are the ones with the fewest frames to spare. Reading the request’s own timing breakdown is covered in diagnosing request queueing and stalled time.

// Paste in the Console on the route you are tuning, then use the page normally.
// pointerover is a close proxy for the router's own trigger: the router adds a
// ~20 ms settle timer on top, so treat any lead time under ~40 ms as zero.
const enteredAt = new WeakMap();
addEventListener('pointerover', (e) => {
  const a = e.target.closest?.('a[href^="/"]');
  if (a) enteredAt.set(a, performance.now());
}, true);
addEventListener('click', (e) => {
  const a = e.target.closest?.('a[href^="/"]');
  const t0 = a && enteredAt.get(a);
  // The gap the router has to work with. Compare it against the p75 duration of
  // this route's __data.json entry, not against the p50 — the fast clickers are
  // the population a preload fails.
  if (t0) console.log(Math.round(performance.now() - t0), 'ms →', a.pathname);
}, true);

Run that against a real session and the shape is consistent across content-heavy apps: a long right tail of readers, and a dense band of fast clicks near zero from people who already know where they are going. Plotting it against the route’s own round trip tells you immediately how much of your traffic a hover preload can serve.

Hover lead time against the __data.json round tripA histogram of hover-to-click lead time for 12,480 desktop navigations on the products route. Buckets run from 0 to 50 milliseconds up to over one second, peaking at 18 percent in the 200 to 300 millisecond bucket. A vertical marker at 96 milliseconds, the median __data.json round trip, splits the chart: the 23 percent of navigations to its left are too fast for a hover preload to help, the 77 percent to its right are fully covered. Hover lead time on /products — 23% of clicks outrun the data round trip % of hovered navigations 9% 14% 17% 15% 18% 13% 9% 5% 96 ms data round trip 0–50 50–100 100–150 150–200 200–300 300–500 500–1k >1k hover → click lead time, 12,480 desktop navigations, one week of field data Left of the marker a preload cannot finish in time; right of it the fetch is fully hidden. Touch sessions collapse into the first bucket: touchstart leads the click by ~90 ms.

The third number worth having is the hit rate — preloads consumed divided by preloads issued. Because the router’s data cache is one slot deep, you can mirror it in five lines:

// src/lib/preload-metrics.js
import { onNavigate, preloadData } from '$app/navigation';

let issued = 0;
let consumed = 0;
let slot = null; // mirrors the router: exactly one entry, overwritten each time

export function trackedPreload(href) {
  issued++;
  slot = new URL(href, location.href).href; // resolve exactly as the router does
  return preloadData(href).catch(() => {}); // a failed speculation is never fatal
}

onNavigate(({ to }) => {
  // A hit is only a hit when the resolved URLs match character for character —
  // a trailing slash or an appended ?ref= parameter is a miss, and the click
  // pays the round trip you already paid for once.
  if (to && to.url.href === slot) consumed++;
  console.log(`preload hit rate ${((consumed / issued) * 100).toFixed(0)}%`);
});

Anything under 30% means the directive is generating server load that no user benefits from. Above 60% the speculation is earning its cost.

Choosing the value per region

With the round trip, the dwell distribution and the load cost in hand, the choice is mechanical. Two branches settle it: whether a wasted data preload is cheap, and whether the population on that route dwells long enough for the fetch to land.

Choosing a SvelteKit preload directive from two measured numbersA decision tree. The root asks whether the server load per preload is under 30 milliseconds and cacheable. The yes branch splits on whether median dwell exceeds the round trip: it does, use preload-data hover on body; it does not, use preload-code viewport plus data on hover and shrink __data.json. The no branch splits on click probability: high, call preloadData after 120 milliseconds of dwell; low on a long link list, use preload-data tap with code on viewport. Picking a directive per region from two measured numbers Server load() per preload: under 30 ms, cacheable? yes no A wasted preload costs one cached query — spend freely load() writes or hits an uncached 90 ms query dwell p75 ≥ RTT dwell p75 < RTT high click rate long link list preload-data = “hover” on body the plain default hit rate ≈ 0.75 preload-code = “viewport” + data on hover shrink __data.json preloadData() after 120 ms dwell code preload first hit rate ≈ 0.65 preload-data = “tap” + code on viewport 1 load() per click RTT is that route’s measured __data.json p50; dwell is the hover→click gap on the same route. Code preloading is cumulative and never evicted, so it belongs in three of the four leaves.

The dwell gate in the two right-hand leaves is a Svelte action. It separates the cheap half of the work from the expensive half, and it is the fix for any region where hover fires far more often than users navigate:

// src/lib/dwell.js
import { preloadCode, preloadData } from '$app/navigation';

export function dwell(node, { ms = 120 } = {}) {
  const url = new URL(node.href, location.href);
  let timer;

  // Code first, unconditionally: the module lands in the module map and the HTTP
  // cache, where the next link's preload cannot evict it, and it costs one Low
  // priority request. preloadCode takes a PATHNAME, not an href.
  const warmCode = () => preloadCode(url.pathname);

  // Data waits for proof of intent. 120 ms is longer than a pointer crossing a
  // link on its way elsewhere and shorter than the 210 ms p50 dwell measured
  // above, so it keeps the hits and drops most of the crossings.
  const armData = () => {
    timer = setTimeout(() => preloadData(url.href).catch(() => {}), ms);
  };

  const enter = () => { warmCode(); armData(); };
  const cancel = () => clearTimeout(timer);

  node.addEventListener('pointerenter', enter);
  node.addEventListener('pointerleave', cancel);
  // Keyboard users never produce pointer events, so the built-in hover trigger
  // skips them entirely; focusin restores parity without adding pointer noise.
  node.addEventListener('focusin', enter);
  // Last chance: a click with no dwell still gets the fetch started one event
  // ahead of the navigation, which is worth 60–120 ms on desktop.
  node.addEventListener('pointerdown', () => preloadData(url.href).catch(() => {}));

  return {
    destroy() {
      cancel();
      node.removeEventListener('pointerenter', enter);
      node.removeEventListener('pointerleave', cancel);
      node.removeEventListener('focusin', enter);
    }
  };
}
<!-- src/routes/products/+page.svelte — the built-in trigger must be turned off
     on this subtree, or both mechanisms fire and the crossing preloads come
     straight back. "off" on the list still inherits nothing from <body>. -->
<ul data-sveltekit-preload-data="off" data-sveltekit-preload-code="viewport">
  {#each products as p (p.id)}
    <li><a href="/products/{p.id}" use:dwell={{ ms: 120 }}>{p.title}</a></li>
  {/each}
</ul>

Deterministic fix protocol

  • [ ] 1. Record the route’s data round trip. Filter the Network panel to __data.json, navigate the route ten times under Slow 4G, and take the p50 and p75 of the Duration column. This is the latency the preload has to beat.
  • [ ] 2. Capture the dwell distribution. Run the Console snippet above through a realistic session on the same route and bucket the results. If p75 dwell is below the p75 round trip, no hover-based directive can help that traffic and the answer is a smaller payload, not an earlier fetch.
  • [ ] 3. Measure the current hit rate. Wire up trackedPreload and read the ratio after fifty navigations. Under 30% is the threshold that justifies changing the directive rather than tuning around it.
  • [ ] 4. Classify every route by load cost. Anything that writes, hits an uncached query above ~30 ms, or bills per call must never sit under a body-level preload-data. Move those routes behind tap or the dwell action; leave the cheap majority on hover.
  • [ ] 5. Turn the built-in trigger off where the action takes over. Set data-sveltekit-preload-data="off" on the subtree and keep data-sveltekit-preload-code="viewport" there. Code preloading is cumulative, so it is the one value that is almost always worth leaving on.
  • [ ] 6. Make the preload URL byte-identical to the navigation URL. Fix a trailingSlash policy, stop appending tracking parameters to href, and confirm in the Console that to.url.href equals the recorded slot. A mismatch shows up as a 0% hit rate with a perfectly healthy-looking Network panel.
  • [ ] 7. Scope invalidation. Replace blanket invalidateAll() calls with depends() keys so a mutation elsewhere in the app does not discard the preload for the link the user is about to click.
  • [ ] 8. Re-measure steps 1–3 and compare at p75. Preloads per navigation should approach 1, the hit rate should clear 60%, and click-to-paint should improve at p75 — a p50-only improvement means you optimised for the readers and left the fast clickers where they were.

Before and after

Measured on a catalogue route: 48 links per view, a 90 ms uncached product query, a 40 ms round-trip connection, one week of field data before and after. “Before” is data-sveltekit-preload-data="hover" on <body>; “after” is the dwell action on the grid, hover retained on the eight-link primary navigation, and tap on the search results.

Metric Before After Change
preloadData calls per navigation 6.4 1.3 −80%
Server load executions per hour 41,200 9,150 −78%
Preload hit rate 16% 71% +55 pts
__data.json bytes per session 512 KB 118 KB −77%
Origin p95 for the product data route 210 ms 96 ms −54%
Click → route paint, p50 118 ms 41 ms −65%
Click → route paint, p75 232 ms 96 ms −59%

The origin row is the one that surprises people: cutting speculative load by 78% removed enough queue depth from the database that the real navigations got faster too, which then shrank the round trip the remaining preloads had to hide. Speculation that misses is not neutral — it competes with the request it was meant to accelerate. The same feedback loop is why document-level speculation needs its own budget, as covered in tuning speculation rules eagerness settings.

FAQ

Hover preloading is on, so why does the click still fetch __data.json?

Three causes cover nearly every instance. The router keeps one preloaded data result at a time, so a hover over any later link discards the earlier one — that is the case drawn in the first diagram. The stored result is keyed on the fully resolved URL, so a trailing slash or an appended ?ref= parameter makes the click a miss. And any invalidation between the preload and the click — an invalidate() call, a changed depends() key, a completed form action — marks the load dirty and forces a fresh request. Check them in that order; the first is the most common and the last is the hardest to see.

Is data-sveltekit-preload-data="tap" worth anything when the click is already happening?

Yes, and more than people expect. pointerdown precedes the click event by 60–120 ms on desktop and by 80–140 ms on touch, because the browser waits for the pointer to be released before dispatching the click. On a warm HTTP/2 or HTTP/3 connection that window covers a typical __data.json round trip outright. The decisive advantage is on the cost side: tap fires once per navigation instead of once per link the pointer crosses, so the server runs exactly one load chain per click — a hit rate of 1.0 by construction.

Do the preload directives fire for keyboard users?

hover does not. It is driven by pointer movement over the anchor, and tabbing to a link produces focusin, not pointer events, so a keyboard user arrives at the click with nothing warm. viewport still applies, because it is driven by IntersectionObserver and does not care how the link was reached, which is another reason to leave preload-code="viewport" on even in regions where data preloading is gated. The focusin listener in the dwell action closes the remaining gap, and because focus is a far stronger intent signal than a pointer crossing, it is worth firing the data preload immediately on focus rather than waiting out the dwell timer.


Related