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.jsand+page.jsexist for a route, the server load runs first and its return value becomes thedataproperty on the universal load’s event. That is a genuine dependency and cannot be parallelised. - A child
loadthat callsawait parent()blocks until every ancestorloadhas resolved. Without that call, parent and child run at the same time. - Awaiting a
fetch()inside aloadbefore 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.
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.
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
- Open DevTools → Network, right-click the column header and enable Priority, then hard-reload the route.
- On the first load, confirm the route CSS shows
Highest, the entrymodulepreloadchunks showHigh, and your hero image showsHigh. An image atLowmeans the<svelte:head>preload is not reaching the rendered HTML — check the SSR response body, not the hydrated DOM. - Clear the panel, then hover a link without clicking. With
preload-data="hover"you should see the route chunk, any shared chunk and__data.jsonappear immediately, atLow,LowandHighrespectively. - Now click. The navigation should produce zero new requests. A repeated
__data.jsonmeans the preload and the navigation disagreed on the URL — a trailing slash, a differing query string, or aninvalidate()that fired between them. - 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.
- Finally, filter the panel to
Docand check the initiator of the very first request. If a click produced a document request rather than afetch, something forced a full navigation — usually a straydata-sveltekit-reload, anhrefthat resolved to a different origin, or a link whosetargetwas 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.
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.
Does preloading work for links added to the DOM after hydration?
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.
Related
- Tuning SvelteKit Data Preload Directives — choosing between hover, tap, viewport and eager per region, and measuring the hit rate
- Fixing SvelteKit Hydration Waterfalls — diagnosing module chains and load serialization that only appear after first paint
- Nuxt Resource Loading Optimization — the same problems solved by a framework that prefetches on viewport entry by default
- Mapping Vite Chunk Graphs to modulepreload — reading the client manifest that decides which hints reach the head
- Up: Framework-Specific Loading Strategies