SvelteKit Resource Loading Optimization

SvelteKit ships one of the smallest client runtimes of any full-stack framework, which makes its network behaviour unusually easy to reason about — and unusually easy to get wrong. Nearly every request a SvelteKit page makes is initiated by one of four things: the modulepreload hints the Vite build writes into the server-rendered head, a load function running on the server, the router’s preload directives reacting to a pointer, or a fetch() you wrote yourself. There is no hidden data layer and no framework-managed image loader. When a SvelteKit page loads slowly, one of those four is misconfigured, and the fix is usually one attribute or one restructured await.

The failure modes are specific and repeatable. A +page.js calls await parent() it does not need, serializing two server round trips that could have overlapped. A link-dense route table inherits data-sveltekit-preload-code="eager" and fires dozens of dynamic imports into the main thread while hydration is still running. A __data.json request sits on the critical path of every client-side navigation because the app turned data preloading off. An LCP image is rendered by a component, so the preload scanner never sees it and the fetch starts after hydration. This page defines each mechanism at the spec level, notes where Chromium, WebKit and Gecko diverge, and walks through a numbered implementation plus a verification workflow that proves the change landed.


How SvelteKit schedules a page load

A SvelteKit app produces two structurally different request trees, and conflating them is the single most common source of confused measurements.

The server-rendered first load. The server runs the matching load functions, renders the component tree to HTML, and streams the response. The %sveltekit.head% placeholder is filled with the stylesheet links and modulepreload links that Vite’s client manifest lists for this route, plus anything you added through <svelte:head>. The serialized result of every load function is written into an inline <script> block at the end of the body, so hydration never refetches it. The browser’s critical path is: HTML → CSS → the entry module graph → hydration. First paint does not need JavaScript; interactivity does.

The client-side navigation. No HTML is produced. The router needs two things: the destination route’s JavaScript modules, and its data. Modules arrive by dynamic import(), resolved against the client manifest so the whole static import graph is requested in one round trip rather than a discovery chain. Data arrives from a request to /<pathname>/__data.json, which re-runs the server load functions and returns their serialized output. Universal load functions then run in the browser on top of that. Everything the user perceives as “navigation latency” is those two requests plus component instantiation.

Those two requests are also independent, which is why preloading works at all: the router can start both the moment it has a reason to believe the user is going somewhere, and both are idempotent GETs. The __data.json request is a plain fetch(), so in Chromium it enters the High band and is not subject to the Low-priority treatment the route chunk gets. On a warm HTTP/2 or HTTP/3 connection both travel as multiplexed streams on the socket the document already opened, so the practical cost of a navigation is one round trip’s latency, not two — provided nothing forces a new connection. That is worth checking if your API lives on a separate origin, because then the data request needs its own handshake and connection coalescing does not apply.

The load-function graph decides your TTFB

Within one route, SvelteKit runs load functions concurrently wherever the data flow permits. A +layout.server.js and a +page.server.js for the same URL start at the same moment. The ordering constraints are explicit and worth memorising:

  • If both +page.server.js and +page.js exist for a route, the server load runs first and its return value becomes the data property on the universal load’s event. That is a genuine dependency and cannot be parallelised.
  • A child load that calls await parent() blocks until every ancestor load has resolved. Without that call, parent and child run at the same time.
  • Awaiting a fetch() inside a load before starting the next one turns two concurrent requests into a chain — the ordinary request waterfall problem, moved to the server.

The graph below is a real route with a 90 ms session lookup in the layout and a 150 ms product lookup in the page. The two server loads overlap by default; a single await parent() in +page.js pushes the reviews fetch behind the layout and adds 150 ms to time to first byte.

Load-function dependency graph for a SvelteKit product route Four boxes: +layout.server.js runs from 0 to 90 milliseconds and feeds +layout.js, while +page.server.js runs from 0 to 150 milliseconds and feeds +page.js. A dashed arrow marked await parent runs from +layout.js down to +page.js, delaying the reviews fetch to 150 to 300 milliseconds. Both branches feed the SSR render box, whose first byte lands at 310 milliseconds. A note explains that removing the await parent edge lets the two server loads overlap and moves first byte 160 milliseconds earlier. Load graph for /products/[id] — the 150 ms that await parent() adds +layout.server.js session · 0 → 90 ms +layout.js no fetch · 90 → 92 ms +page.server.js product · 0 → 150 ms +page.js reviews · 150 → 300 ms SSR render + stream waits for every awaited load first byte at 310 ms await parent() +150 ms serialized Drop the await parent() edge and +page.js starts at 0 ms — the two server loads overlap. Both resolve by 150 ms, SSR starts 160 ms earlier, and every downstream fetch moves with it.

event.fetch writes its responses into the HTML

The fetch handed to a load function is not the global one. It inherits the incoming request’s cookies and authorization header, resolves relative URLs against the current origin, and — the part that matters for scheduling — serializes the responses it makes during server rendering into the HTML, so that hydration reads them from the payload instead of reissuing them. That eliminates the classic double-fetch of server-rendered apps, but it also means a large API response is now inline HTML bytes on the critical path. A 400 KB JSON blob fetched in a load is 400 KB the browser must download before it can finish parsing the document, at Highest priority, ahead of your images.

Two levers keep that in check: return only the fields the component renders, and use filterSerializedResponseHeaders in the handle hook if you need the client to see specific response headers rather than the default (none).

There is a second consequence that catches people out. Because event.fetch resolves relative URLs against the current origin and runs inside the server process, a call to your own +server.js endpoint during SSR does not go over the network — SvelteKit dispatches it directly to the handler. That is free, but it also means a slow endpoint’s latency is now server-side render time rather than a parallel client request, so it lands squarely on TTFB. If an endpoint is slow and its data is not needed for first paint, stream it rather than awaiting it.

From Vite’s chunk graph to the browser’s head

Everything in the head that you did not write yourself comes from one file: the client manifest that Vite emits at .svelte-kit/output/client/.vite/manifest.json. For each entry point it records the built file, its CSS, and — crucially — the transitive list of static imports:

{
  "src/routes/products/[id]/+page.svelte": {
    "file": "_app/immutable/nodes/4.Bq7wP2xk.js",
    "css": ["_app/immutable/assets/4.CvT9m2ab.css"],
    "imports": ["_app/immutable/chunks/D3xKq1.js", "_app/immutable/chunks/BpLm44.js"],
    "dynamicImports": ["src/lib/ChartPanel.svelte"]
  }
}

SvelteKit reads that record for the matched route and emits one head hint per entry in file, css and imports. Entries under dynamicImports get no hint — that is the whole point of a dynamic import, and it is your main tool for keeping the eager set small.

The scheduling consequence is direct: anything that pulls a module into a route’s static import graph adds a High-priority head request to that route’s first paint. A top-level import { format } from 'date-fns' in +layout.svelte is not a bundle-size problem, it is a request-scheduling problem — the chunk is now hinted on every route in the app, competing with the LCP image for the first few connection slots. The same graph feeds preloadCode(), so a bloated static graph also makes every speculative navigation preload heavier. Reading it deliberately is covered in mapping Vite chunk graphs to modulepreload.

Note also what SvelteKit does not emit: nothing for images, nothing for fonts, nothing for third-party origins. Those are entirely yours to declare, which is why steps 5 and 6 below exist.


The preload directives, precisely

SvelteKit’s router looks for two data attributes when it needs to decide whether to speculatively load a link’s destination. Both are resolved by walking up the DOM from the anchor element to the first ancestor that declares the attribute, so a value on <body> in src/app.html is the app-wide default and any subtree can override it.

data-sveltekit-preload-code controls the destination route’s JavaScript and CSS. data-sveltekit-preload-data controls its load functions, and because running a universal load requires the route module, a data preload always implies a code preload. That precedence is the detail most people miss: setting preload-data="hover" on the body makes a preload-code="hover" alongside it redundant, while preload-code="viewport" still adds value because it fires earlier than any pointer event.

Directive Values What is fetched When
data-sveltekit-preload-code eager Route module + its static imports + route CSS Immediately after the navigation that rendered the link
viewport same When the link intersects the viewport
hover same pointerover on desktop, touchstart on touch
tap same pointerdown / touchstart only
off nothing
data-sveltekit-preload-data hover Route module and its load result pointerover, or touchstart on touch
tap same pointerdown / touchstart
off nothing

Choosing between them is a bandwidth-versus-latency trade with one asymmetry worth internalising: a code preload is idempotent and cheap to waste, because an unused route chunk sits in the HTTP cache and costs one Low-priority request; a data preload is neither, because it executes your server load functions and therefore hits your database. On a marketing site, preload-data="hover" everywhere is correct. On an app whose load functions are expensive, the right default is preload-code="viewport" plus preload-data="tap", so the modules are warm for everyone and the query runs only for the link actually chosen.

eager and viewport only apply to links that exist in the DOM immediately after a navigation settles. A link revealed later by a conditional block, a dialog or an infinite scroll never triggers them; only hover and tap, which are event-driven, still work. For those cases you call the router API directly.

The state machine below traces one product link through the escalation. Each state records exactly what has been fetched, so you can see what a click costs from each of them.

Preload escalation states for one SvelteKit link Four states arranged in a clockwise loop. Idle has made no requests. Viewport entry or hover moves to Code preloaded, which has fetched the route chunk, a shared chunk and the route CSS in three requests totalling 41 kilobytes. A pointerover event moves to Data preloaded, which adds one 6 kilobyte __data.json request. A click 230 milliseconds later moves to Navigated, which issues no new requests because the router resolves everything from cache. Preload escalation for one product link — what each state has already fetched 1 · Idle link painted, nothing fetched 0 requests · 0 KB 2 · Code preloaded route chunk + shared + CSS 3 requests · 41 KB 3 · Data preloaded __data.json in router cache 1 request · 6 KB 4 · Navigated components swapped in place 0 new requests viewport entry or hover preloadCode() → import() pointerover preloadData() runs load() click, 230 ms later router resolves from cache preload-code="viewport" gets you to state 2; preload-data="hover" gets you to state 3. Only state 3 makes the click free — code alone still leaves the load() round trip on the path.

Engine differences that change the outcome

Behaviour Chromium (Blink) Safari (WebKit) Firefox (Gecko)
<link rel="modulepreload"> in the SSR head Fetches, parses and compiles; module map is populated, so import() resolves with no request Honoured from Safari 17; ignored entirely below that, leaving a discovery waterfall Honoured from Firefox 115; ignored below
Priority of preloadCode()'s dynamic import() Low — it is a speculative script fetch, not a blocking one Roughly medium band; not surfaced in the inspector Lowest band
Priority of the __data.json fetch() High (the fetch() default) High-equivalent High band
hover trigger on a touchscreen Falls back to touchstart, giving ~80–120 ms of lead time instead of ~200 ms Same fallback; pointerover also fires once on tap, so the preload can run twice — the router deduplicates Same fallback
fetchpriority on a preloaded LCP image Chrome 101+ Safari 17.2+ Firefox 132+

The Safari and Firefox rows on modulepreload are the reason SvelteKit exposes kit.output.preloadStrategy. If your analytics still show meaningful Safari 16 traffic, the default strategy silently gives those users a two-round-trip module chain on every navigation, and the fix is a build-config change rather than anything in your components. The mechanics of the hint itself are covered in modulepreload and ES module loading.


Spec and API reference

Router and build APIs that move requests

API Signature / value Scheduling effect
preloadCode ($app/navigation) preloadCode(...pathnames: string[]) Dynamic-imports the matching route modules; no load runs, no data fetched
preloadData ($app/navigation) preloadData(href: string) Imports modules and runs load; result is reused by the next navigation to that href
goto ($app/navigation) goto(url, { invalidateAll }) invalidateAll: true forces a fresh __data.json even if preloaded
invalidate / depends invalidate('app:cart') Marks a load dirty; the next navigation refetches instead of reusing the preload
kit.output.preloadStrategy 'modulepreload' | 'preload-js' | 'preload-mjs' Chooses the head hint type for the entry graph
kit.inlineStyleThreshold bytes (default 0) CSS under the threshold is inlined, removing a render-blocking request
resolve(event, { preload }) ({ type, path }) => boolean Filters which build assets get a head hint; default is type === 'js' || type === 'css'
export const prerender true | false | 'auto' Static HTML at build time; removes server load from the request path
export const ssr true | false false ships an empty shell — the preload scanner sees nothing but the entry script
export const csr true | false false ships zero client JS; no hydration, no router, no preloading

Other data-sveltekit-* attributes worth knowing

Attribute Values Effect
data-sveltekit-reload present / off Forces a full document navigation, discarding any preloaded state
data-sveltekit-replacestate present / off Replaces the history entry; no effect on fetching
data-sveltekit-noscroll present / off Suppresses scroll reset; no effect on fetching
data-sveltekit-keepfocus present / off Keeps focus after navigation; no effect on fetching

Browser support matrix

Feature Chrome Edge Firefox Safari
rel="modulepreload" 66 79 115 17
rel="preload" as="script" 50 79 85 11.1
IntersectionObserver (viewport trigger) 51 15 55 12.1
fetchpriority attribute 101 101 132 17.2
Streaming response body consumed incrementally 43 79 65 10.1
103 Early Hints processing 103 103 120 17

Step-by-step implementation

Step 1 — Inventory what the build already emits

Before adding a single hint, read the head SvelteKit generates. Open the page, disable JavaScript from the DevTools command menu, reload, and view source. Count the modulepreload links: each one is a High-priority script fetch that competes with your stylesheet and LCP image. More than roughly a dozen on a content route means shared chunks are being pulled into the entry graph that only one component needs. Note the inline <script> at the end of the body — that is your serialized load output, and its byte size is part of the HTML transfer.

Write three numbers down before you change anything: the count of head hints, the size of the serialized payload, and the time from the request start to the first img request in the Network panel. Those three move in opposite directions when you tune badly — adding a font preload lowers the third and raises the first — so having the baseline is what tells you whether a change was a win or a reshuffle. It is the same discipline as reading a network waterfall on any other stack; SvelteKit just makes the inputs unusually few.

Step 2 — Declare a base preload policy, then override it

<!-- src/app.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!-- Warm the API origin during HTML parse. The router's first __data.json
         request happens after hydration, so without this it pays DNS + TCP + TLS
         at the exact moment the main thread is busiest. -->
    <link rel="preconnect" href="https://api.example.com" crossorigin />
    %sveltekit.head%
  </head>
  <!-- preload-data implies code preloading, so one attribute covers both.
       "hover" buys ~200 ms of lead time on desktop, enough to hide a 96 ms
       __data.json round trip completely. -->
  <body data-sveltekit-preload-data="hover">
    <div>%sveltekit.body%</div>
  </body>
</html>

Then relax the policy where speculation is wasteful. A search-results list or a paginated table will happily fire a preload for every row the pointer crosses:

<!-- src/routes/search/+page.svelte -->
<!-- "tap" waits for pointerdown instead of pointerover. On a list the user is
     scanning rather than reading, pointerover fires for every row the cursor
     crosses; pointerdown fires once, for the row actually chosen. -->
<ul data-sveltekit-preload-data="tap">
  {#each results as hit (hit.id)}
    <li><a href="/products/{hit.id}">{hit.title}</a></li>
  {/each}
</ul>

<!-- The one link most users take next. Eager code preload puts its module in
     the map during idle time, leaving only the data round trip at click. -->
<a href="/checkout" data-sveltekit-preload-code="eager">Go to checkout</a>

Step 3 — Flatten the load-function waterfall

The rule is: start every independent request before awaiting any of them, and never await parent() for data you do not read.

// src/routes/products/[id]/+page.server.js
export async function load({ params, fetch, setHeaders }) {
  // Both requests are STARTED here, before either is awaited. Promise.all then
  // waits for the slower one only — 150 ms total instead of 150 + 90 ms.
  const productReq = fetch(`/api/products/${params.id}`);
  const stockReq = fetch(`/api/stock/${params.id}`);

  const [product, stock] = await Promise.all([
    productReq.then((r) => r.json()),
    stockReq.then((r) => r.json())
  ]);

  // Reviews are below the fold and slow (~400 ms). Returning the UNAWAITED
  // promise streams it: the shell flushes at 150 ms and the reviews chunk
  // arrives later in the same response, so TTFB is not held hostage.
  const reviews = fetch(`/api/reviews/${params.id}`).then((r) => r.json());

  // Let the CDN reuse this render for 60 s. TTFB is the root of the request
  // tree, so every child fetch moves earlier by whatever this saves.
  setHeaders({ 'cache-control': 'public, max-age=0, s-maxage=60' });

  return { product, stock, streamed: { reviews } };
}
<!-- src/routes/products/[id]/+page.svelte -->
<script>
  let { data } = $props();
</script>

<h1>{data.product.title}</h1>

<!-- The awaited promise resolves after first paint. The page is interactive
     with the product visible while this block is still pending, which is the
     entire point of streaming a non-critical load value. -->
{#await data.streamed.reviews}
  <p>Loading reviews…</p>
{:then reviews}
  <ReviewList {reviews} />
{/await}

If a child load genuinely needs one field from its parent, call parent() after starting its own requests:

// src/routes/products/[id]/+page.js
export async function load({ data, parent, fetch }) {
  // Start the child's own request FIRST, then await the parent. Both are now
  // in flight simultaneously; awaiting parent() on line one would have made
  // this a two-hop chain for no benefit.
  const relatedReq = fetch(`/api/related/${data.product.id}`);
  const { locale } = await parent();

  return { ...data, locale, related: await relatedReq.then((r) => r.json()) };
}

Step 4 — Keep __data.json small

Everything a server load returns is serialized twice: once inline in the SSR HTML, once again in __data.json on every client-side navigation to that route. Return the rendered shape, not the source shape. A list endpoint that hands back 60 fields per row when the card renders four is a 10× payload multiplier applied to every navigation. Where the same object appears in a layout and a page load, return it from the layout only and read it through page.data in the child — SvelteKit deduplicates layout data across navigations that keep the layout mounted, but not duplicated fields you return twice yourself.

// src/routes/products/+page.server.js
export async function load({ fetch }) {
  const rows = await fetch('/api/products?limit=48').then((r) => r.json());

  // Project down to the four fields the card renders. This payload is
  // serialized into the SSR HTML *and* re-sent as __data.json on every
  // client-side navigation back to this route, so the projection is paid
  // back on every visit, not just the first.
  return {
    products: rows.map(({ id, slug, title, price }) => ({ id, slug, title, price }))
  };
}

The other lever on this route is prerender. If the data changes on a deploy cadence rather than a request cadence, export const prerender = true turns both the HTML and the __data.json into static files, which removes your server from the navigation path entirely.

Step 5 — Make the LCP image scanner-visible

SvelteKit has no image component in core, so nothing emits a hint for you. The hero image must be either in app.html (if it is the same for every route) or in <svelte:head> for the route that owns it:

<!-- src/routes/+page.svelte -->
<svelte:head>
  <!-- Rendered into the SSR response, so the preload scanner dispatches this
       during HTML parse — before CSS is parsed and long before hydration.
       fetchpriority="high" lifts it out of the Low image band so it competes
       with the stylesheet instead of queueing behind the module graph. -->
  <link
    rel="preload"
    as="image"
    href="/img/hero-1280.avif"
    imagesrcset="/img/hero-800.avif 800w, /img/hero-1280.avif 1280w"
    imagesizes="(max-width: 720px) 100vw, 1280px"
    fetchpriority="high"
  />
</svelte:head>

<img
  src="/img/hero-1280.avif"
  srcset="/img/hero-800.avif 800w, /img/hero-1280.avif 1280w"
  sizes="(max-width: 720px) 100vw, 1280px"
  width="1280"
  height="640"
  fetchpriority="high"
  alt="Product dashboard overview"
/>

The imagesrcset/imagesizes pair must match the <img> exactly, or the browser selects a different candidate and you have paid for two downloads. That mismatch is the most common cause of the preloaded but not used warning.

Step 6 — Tune the preload strategy and the head filter

// svelte.config.js
import adapter from '@sveltejs/adapter-node';

export default {
  kit: {
    adapter: adapter(),
    // Inline any route stylesheet under 4 KB directly into the HTML. A small
    // CSS file costs a full round trip as a <link>; inlined it costs bytes on
    // a connection that is already open, and stops blocking render.
    inlineStyleThreshold: 4096,
    output: {
      // 'modulepreload' is right when your floor is Safari 17 / Firefox 115.
      // Below that the hint is ignored and each dynamic import discovers its
      // dependencies one round trip at a time; 'preload-mjs' emits
      // <link rel="preload" as="script"> instead, which those versions honour.
      preloadStrategy: 'modulepreload'
    }
  }
};
// src/hooks.server.js
export async function handle({ event, resolve }) {
  return resolve(event, {
    // The default filter emits hints for 'js' and 'css' only. Fonts are
    // discovered from CSS, which is itself discovered from HTML — a three-hop
    // chain. Adding the one font the shell needs collapses that to one hop.
    preload: ({ type, path }) => {
      if (type === 'font') return path.endsWith('inter-var-subset.woff2');
      return type === 'js' || type === 'css';
    }
  });
}

Be conservative here. Every extra entry is a High-priority fetch in the head, and a head full of hints is functionally the same as having no priorities at all — the point of preloading critical assets is that the set is small.

Step 7 — Preload on your own intent signal

For links the router cannot see — a command palette, a wizard’s next step, a row that only becomes clickable after validation — drive the same machinery yourself:

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

// Called when a wizard step validates. preloadCode runs first and cheaply
// (modules only); preloadData is deferred to the idle callback because it runs
// load(), which issues a real network request we do not want racing hydration.
export function warmNextStep(href) {
  preloadCode(href);

  const schedule = window.requestIdleCallback ?? ((fn) => setTimeout(fn, 200));
  schedule(() => {
    // preloadData resolves to the same object the next navigation would use,
    // so the click becomes a pure component swap.
    preloadData(href).catch(() => {
      /* a failed speculative load must never surface to the user */
    });
  });
}

Two properties of these functions are easy to overlook. preloadCode takes pathnames, not hrefs, and they must match a route the client manifest knows about; passing a URL with a query string or an external origin throws rather than silently doing nothing, which is deliberate — a typo in a speculative optimisation should be loud in development. And preloadData resolves to the same object the next navigation would consume, including redirect results, so calling it twice for the same href is free: the router returns the in-flight promise rather than issuing a second request. That makes it safe to call from a pointerenter handler that fires repeatedly.


Verification workflow

DevTools Network panel

  1. Open DevTools → Network, right-click the column header and enable Priority, then hard-reload the route.
  2. On the first load, confirm the route CSS shows Highest, the entry modulepreload chunks show High, and your hero image shows High. An image at Low means the <svelte:head> preload is not reaching the rendered HTML — check the SSR response body, not the hydrated DOM.
  3. Clear the panel, then hover a link without clicking. With preload-data="hover" you should see the route chunk, any shared chunk and __data.json appear immediately, at Low, Low and High respectively.
  4. Now click. The navigation should produce zero new requests. A repeated __data.json means the preload and the navigation disagreed on the URL — a trailing slash, a differing query string, or an invalidate() that fired between them.
  5. Repeat with Slow 4G throttling. On a slow connection the hover lead time is unchanged (~200 ms) while the round trip grows, so the fraction of latency hidden by preloading shrinks; that is the number worth reporting, not the fast-network one.
  6. Finally, filter the panel to Doc and check the initiator of the very first request. If a click produced a document request rather than a fetch, something forced a full navigation — usually a stray data-sveltekit-reload, an href that resolved to a different origin, or a link whose target was set. A full navigation throws away every preload the router made.

The panel below is the failure case: preload-code is on but preload-data is off, so the code arrives on hover and the data round trip still lands after the click.

Network panel for a SvelteKit navigation with code preloading only A five-row network panel. The first three rows — a route chunk, a shared chunk and the route stylesheet — are fetched between 12 and 54 milliseconds on hover, at Low, Low and High priority. A dashed marker at 230 milliseconds shows the click. After it, __data.json takes 96 milliseconds and a reviews fetch takes 74 milliseconds, both at High priority, so the navigation is 96 milliseconds slower than it needs to be. Network panel — navigating to /products/42 with preload-code="hover" only click +230 ms Request Initiator Priority Time Timeline · 0 → 360 ms products._id_-Bq7wP.js modulepreload Low 42 ms chunk-D3xKq1.js modulepreload Low 26 ms _page-CvT9m2.css stylesheet High 31 ms click at +230 ms — the router must now wait for load() __data.json (products/42) fetch (router) High 96 ms reviews?id=42 fetch (load) High 74 ms With preload-data="hover", rows 4 and 5 run during the 230 ms of hover — the click then paints in one frame with zero network requests.

PerformanceObserver spot check

// Paste in the Console before hovering. Reports every request SvelteKit's
// build or router initiated, so you can confirm the preloads happened BEFORE
// the click timestamp rather than after it.
new PerformanceObserver((list) => {
  for (const e of list.getEntriesByType('resource')) {
    if (!/\/_app\/|__data\.json/.test(e.name)) continue;
    console.log({
      file: e.name.split('/').pop().split('?')[0],
      initiator: e.initiatorType,       // 'link' = head hint, 'fetch' = router
      startedAt: Math.round(e.startTime),
      duration: Math.round(e.duration),
      // 0 with a non-zero decodedBodySize means the response was reused from
      // cache — proof a preload was consumed rather than duplicated.
      transferSize: e.transferSize
    });
  }
}).observe({ type: 'resource', buffered: true });

What good looks like

Measure the same navigation in four configurations and the ranking is stable across connection speeds; only the gaps change. These are the numbers from the route in the diagrams above, on a 40 ms round-trip connection:

Configuration Requests at click Click → paint Notes
preload-data="off", preload-code="off" 5 ~310 ms Module discovery chain, then the data round trip
preload-code="viewport" 2 ~180 ms Modules warm; __data.json still on the path
preload-data="hover" 0 ~35 ms Component instantiation only
preload-data="hover" + prerender 0 ~30 ms Static __data.json served from the edge

The jump that matters is row two to row three. Code preloading alone removes the cheaper half of the problem; the load round trip is what the user feels.

Build-level check

Run npx vite build and read .svelte-kit/output/client/.vite/manifest.json. For the route you care about, follow its imports array: those are exactly the files that become modulepreload hints. Any shared chunk above ~40 KB appearing in most routes’ import lists is entry-graph weight paid on every page; move the module behind a dynamic import() inside a component so it drops out of the eager set.


Edge cases and gotchas

csr = false removes the router, and everything it does

export const csr = false ships no client JavaScript for that route at all. It is an excellent choice for a privacy policy or a receipt page, and it deletes hydration cost outright — but it also deletes the router, which means no preloading of any kind happens from that page, and links out of it are full document navigations. If such a page is a common entry point, put the speculation somewhere the browser can still act on it: a <link rel="prefetch"> in <svelte:head> for the single most likely next document.

Preloading is speculative, invalidation is not

preloadData stores its result against the destination URL, and the router reuses it if the navigation happens soon after. Anything that marks a load dirty in between — invalidate('app:cart'), a depends() dependency changing, a form action returning — discards the preload and forces a fresh __data.json. Apps that call invalidateAll() on a timer or after every mutation get the cost of preloading with none of the benefit. Scope invalidation to specific keys with depends() so unrelated preloads survive.

ssr = false blinds the preload scanner

Setting export const ssr = false on a route ships a shell containing nothing but the entry script tag and the head hints Vite generated for the entry graph. The preload scanner has no images, no fonts and no route CSS to discover, so everything below the entry graph waits for hydration. If a route must be client-only, compensate by declaring its critical assets in app.html or <svelte:head>; those are still server-rendered even when the component tree is not.

Hover fires twice on touch devices

On a touchscreen, a tap generates pointerover immediately before pointerdown. Both trigger a hover-configured preload. The router deduplicates by URL, so you do not get two requests — but you do get the preload and the navigation starting within a few milliseconds of each other, which means the preload provides no lead time at all on mobile. Mobile navigation latency is therefore the unhidden round trip, and the only way to shrink it is to shrink __data.json or move the route to prerender.

Streamed promises and error handling

A promise returned inside a server load streams its resolution after the shell. Two constraints follow. First, streaming requires a streaming-capable adapter and platform; a platform that buffers the whole response defeats it silently and you simply get a slower TTFB with no error. Second, a rejected streamed promise cannot produce an error page — the response headers and shell have already been sent — so it must be handled in the component’s {:catch} branch or the user sees a broken region.

Service worker precaching versus the critical path

The SvelteKit service worker template iterates build and files from $service-worker and caches everything on install. Registered in the app’s root layout during hydration, that install fires while the browser is still fetching route assets, and every precache request competes for the same connection pool. Register it from a requestIdleCallback, or filter build down to the entry graph, so a first-time visitor is not downloading the entire app while trying to read the page. Injecting hints later from the worker is covered in injecting resource hints from a service worker.

Speculation Rules and the router want the same job

A <script type="speculationrules"> block asking the browser to prerender same-origin links operates one level above SvelteKit’s router: it fetches and renders the document, entry graph and all, in a hidden tab. If both are active on the same links you get a document prerender and a router preload of the same route, and the router’s work is discarded when the click activates the prerendered document instead. Pick one per link class. Document-level prerendering suits entry points from a landing page, where the destination is a different layout; the router’s preload suits in-app navigation, where the layout is already mounted and swapping components is far cheaper than rebuilding a document. The eagerness settings that decide when speculation fires are covered in speculation rules: prefetch and prerender.

CSP and the inlined data script

If you enable kit.csp with a nonce or hash mode, the inline payload script is covered automatically — but a hand-written inline <script> in app.html is not, and a violated CSP silently drops it. The symptom is a route that hydrates in development and shows an empty shell in production. Verify with a CSP report-only header before shipping the enforcing one.

The preload URL must match the navigation URL exactly

preloadData keys its cached result on the resolved href. A hover over /products/42 followed by a click that navigates to /products/42/ — or to /products/42?ref=list because a click handler appended tracking parameters — is a cache miss, and you pay the round trip you thought you had hidden. Two habits prevent it: pick one trailing-slash policy and enforce it with the trailingSlash page option so links and the router agree, and append analytics parameters after navigation rather than to the href. This is the single most common reason a correctly configured preload-data="hover" shows no improvement in the field while looking perfect in local testing.

Form actions bypass the preload path entirely

A <form method="POST"> enhanced with use:enhance posts to an action and then applies the returned data, optionally re-running load functions. None of that touches the preload cache, and the default behaviour after a successful action is to invalidate all load functions for the current page. On a route where a small mutation triggers a large re-read, return the updated record from the action and use a custom enhance callback to apply it, instead of letting the framework refetch the entire __data.json.

Prerendered routes and __data.json

A prerendered route writes its data to a static __data.json beside the HTML at build time. Client-side navigations to it therefore hit a cacheable static file rather than your server, which is the cheapest possible navigation — and a strong reason to prerender marketing and documentation routes even in an otherwise dynamic app. The trade is staleness: that file is a build-time snapshot, so combine it with depends() plus an invalidate() on the paths whose freshness actually matters.


FAQ

Does data-sveltekit-preload-data also preload the route’s JavaScript?

Yes. Running a universal load requires the route module, so a data preload always implies a code preload. Setting preload-data="hover" therefore supersedes a preload-code of hover, tap or off on the same link. Only eager and viewport add anything on top, because they fire before any pointer event does.

Why does my page still fetch __data.json when the data was inlined in the HTML?

The inlined payload serves the initial server-rendered load only. __data.json belongs to client-side navigation, where no fresh HTML arrives but the server load functions must still run. Seeing it during the first load means something invalidated the data: an invalidate() call in an onMount, a depends() dependency that changed, or a goto() to the URL you are already on.

Should I set data-sveltekit-preload-code="eager"?

Only on a small app, or a small region of a large one. Eager preloading dynamic-imports every visible link’s route module as soon as the page hydrates, and that is main-thread compile work competing with hydration itself. On a table with fifty links it is fifty imports. viewport gets nearly the same navigation latency for a fraction of the cost, and hover costs almost nothing at all.

hover and tap do, because the router resolves the directive by walking up from the anchor at event time. eager and viewport only apply to links present immediately after a navigation settles, so anything revealed later by a conditional block, a modal or an infinite scroll is never preloaded by them. Call preloadCode or preloadData directly for those.

How do I preload a font that only one route uses?

Put the <link rel="preload" as="font" crossorigin="anonymous"> inside <svelte:head> in that route’s +page.svelte. It is rendered into the server response for that route only, so the preload scanner dispatches it during HTML parse and no other route pays for the request. The handle hook’s preload filter is the right tool when the font is needed app-wide instead.