What Prefetching Is and Why It Matters

Client-side navigation in SvelteKit is already fast compared to full page loads. When a user clicks a link, SvelteKit does not reload the browser; it runs the destination route’s load function, receives the data, and re-renders only the components that changed. That saves the cost of reloading JavaScript, CSS, and shared layout components.

But the load function still has to run when the user clicks. If that load function makes a network request, there is a latency gap between the click and the content appearing, even on fast connections. On mobile networks or behind slow APIs, that gap becomes noticeable.

Prefetching closes that gap by starting the load function before the user clicks, while they are hovering over the link, or while the link is entering the viewport, and caching the result so that when the actual navigation happens, the data is already available. For applications with predictable navigation patterns, content-heavy listing pages, and known hot paths, this can make navigation feel genuinely instant.

SvelteKit provides two forms of prefetching: data prefetching (running load functions early and caching the results) and code prefetching (downloading the JavaScript for a route before it is needed). Both can be applied declaratively via HTML attributes on anchor elements, or programmatically via functions from $app/navigation.


The Navigation Latency Gap

Consider a blog with a list of posts on /blog. Each post has a title, excerpt, and a link to /blog/[slug]. A reader scans the list, finds a post that interests them, and clicks the link. The post load function fetches the full article from an API. On a 100ms round trip, the user waits at least 100ms after clicking before anything changes. On a mobile network with a 400ms round trip, the wait becomes clearly noticeable.

This latency has two components: network time (waiting for the server to respond) and code time (waiting for the JavaScript bundle for the route to download if it has not been fetched yet). Both contribute to the gap between a user’s intent and the content appearing.

Now imagine the user pauses their mouse over the post title for 150ms before clicking, which is typical human behaviour. Without prefetching, those 150ms are wasted time. With prefetching, SvelteKit can start the load function on hover and have the data ready before the click is even registered. The user experiences zero perceptible delay.

This is the core value of prefetching: it converts idle time the user spends pointing and thinking into productive work the browser does fetching data and code. It is one of the few performance optimisations that provides a benefit proportional to normal human browsing speed, not just to artificially slow network conditions.


data-sveltekit-preload-data: The Declarative Approach

The simplest way to enable prefetching is with the data-sveltekit-preload-data attribute on an anchor element or on a parent container. SvelteKit will start prefetching the destination route’s load function when the trigger condition is met.

The attribute accepts two values: hover and tap.

hover

With hover, SvelteKit starts prefetching when the user moves their pointer over the link, or when the link receives focus via keyboard. This is the most aggressive trigger and gives the most time to prefetch before a click.

<!-- src/routes/blog/+page.svelte -->

<script lang="ts">
	import type { PageProps } from './$types'
	let { data }: PageProps = $props()
</script>

<ul>
	{#each data.posts as post}
		<li>
			<a href="/blog/{post.slug}" data-sveltekit-preload-data="hover">
				{post.title}
			</a>
		</li>
	{/each}
</ul>

The attribute can also be placed on a parent element to apply to all descendant links:

<!-- src/routes/blog/+page.svelte -->

<ul data-sveltekit-preload-data="hover">
	{#each data.posts as post}
		<li>
			<a href="/blog/{post.slug}">{post.title}</a>
		</li>
	{/each}
</ul>

SvelteKit applies data-sveltekit-preload-data="hover" to the <body> element by default in new projects, via a data-sveltekit-preload-data attribute on the root <body> tag in app.html. This means hover prefetching is typically already enabled across your entire application without any per-link annotation.

tap

With tap, prefetching triggers on touchstart (mobile) or mousedown (desktop), which is the moment the user’s finger touches the screen or presses the mouse button, before the click event fires. This is a shorter window than hover but is more mobile-friendly since touch devices do not have hover states.

<a href="/blog/{post.slug}" data-sveltekit-preload-data="tap">
	{post.title}
</a>

On desktop, tap is less effective than hover because the time between mousedown and click is typically 100-200ms, compared to the several hundred milliseconds a user may spend hovering. On touch devices, tap is the best available option.

A Note on Reduced Data Mode

SvelteKit will never trigger data prefetching if the user has enabled data-saving mode in their browser or OS. When navigator.connection.saveData is true, both data-sveltekit-preload-data and preloadData are silently skipped. This is the correct behaviour: prefetching is a performance hint, not a requirement, and it should respect user preferences.


data-sveltekit-preload-code: Declarative Code Prefetching

data-sveltekit-preload-code is the declarative counterpart for code-only prefetching. Unlike data-sveltekit-preload-data, it downloads the JavaScript bundle for a route without running its load function. It accepts four values in decreasing eagerness:

  • "eager" — preloads the code for every link on the page immediately after each navigation, without any user interaction required
  • "viewport" — preloads the code for links as they scroll into the viewport
  • "hover" — preloads on pointer hover or keyboard focus (same trigger as data-sveltekit-preload-data="hover")
  • "tap" — preloads on touchstart or mousedown

Note that "viewport" and "eager" only apply to links that are present in the DOM immediately following navigation. Links added later — inside {#if} blocks, for example — are not eagerly preloaded; they fall back to hover or tap behaviour.

<!-- src/routes/blog/+page.svelte -->

<!-- Preload the JavaScript bundle for each post as it enters the viewport -->
<ul data-sveltekit-preload-code="viewport">
	{#each data.posts as post}
		<li>
			<a href="/blog/{post.slug}">{post.title}</a>
		</li>
	{/each}
</ul>

Use data-sveltekit-preload-code when you want the route’s JavaScript available before navigation but cannot predict or afford to run the load function in advance — for example, if the load function hits a rate-limited API or makes expensive database queries.


preloadData: Programmatic Data Prefetching

The preloadData function from $app/navigation is the programmatic equivalent of data-sveltekit-preload-data. Call it with a URL string and SvelteKit will run that route’s load functions and cache the result, exactly as if the user had hovered over the link.

import { preloadData } from '$app/navigation'

// Start prefetching a specific route
await preloadData('/blog/introduction-to-sveltekit')

The function returns a promise that resolves with the result of running the route’s load functions. The resolved shape is a discriminated union:

type PreloadResult =
	| { type: 'loaded'; status: number; data: Record<string, any> }
	| { type: 'redirect'; location: string }

In practice, you rarely need to await it; fire it and forget when the intent is just to warm the cache.

Programmatic prefetching is useful in situations where hover state is not meaningful, for instance when a component renders on the server and the “natural trigger” for prefetching is something other than pointer proximity.

Prefetching on IntersectionObserver

A common pattern is to prefetch a route when its link scrolls into the viewport. This is useful for paginated or infinite-scroll lists where the user is clearly moving toward a section of the page:

<!-- src/routes/blog/+page.svelte -->

<script lang="ts">
	import { preloadData } from '$app/navigation'
	import type { PageProps } from './$types'

	let { data }: PageProps = $props()

	function prefetchOnVisible(node: HTMLAnchorElement) {
		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (entry.isIntersecting) {
						const href = node.getAttribute('href')
						if (href) preloadData(href)
						observer.disconnect()
					}
				}
			},
			{ rootMargin: '100px' }
		)

		observer.observe(node)

		return {
			destroy() {
				observer.disconnect()
			}
		}
	}
</script>

<ul>
	{#each data.posts as post}
		<li>
			<a href="/blog/{post.slug}" use:prefetchOnVisible>
				{post.title}
			</a>
		</li>
	{/each}
</ul>

The use:prefetchOnVisible Svelte action attaches an IntersectionObserver to each link. When the link enters the viewport (with a 100px margin so prefetching starts slightly before the link is fully visible), preloadData is called and the observer disconnects to avoid repeat calls.

Prefetching on Explicit User Intent

Another pattern is to prefetch in response to clear user intent, such as a search result being highlighted or a card being focused:

<!-- A search results component -->

<script lang="ts">
	import { preloadData } from '$app/navigation'

	type SearchResult = { title: string; href: string }

	let { results }: { results: SearchResult[] } = $props()

	function handleResultFocus(href: string) {
		preloadData(href)
	}
</script>

{#each results as result (result.href)}
	<a href={result.href} onfocus={() => handleResultFocus(result.href)}>
		{result.title}
	</a>
{/each}

preloadCode: Prefetching Route JavaScript

preloadCode is distinct from preloadData. Where preloadData runs load functions and caches the data, preloadCode only downloads the JavaScript bundle for a route without running its load function. It ensures the route’s code is available in the browser cache before the user navigates there.

import { preloadCode } from '$app/navigation'

// Warm the JavaScript cache for all blog post routes
await preloadCode('/blog/*')

preloadCode accepts a pathname or glob pattern, not a SvelteKit file-system route pattern. Use /blog/* to match src/routes/blog/[slug]/+page.svelte, or a concrete URL like /blog/my-post to match a specific page. The file-system bracket notation [slug] is not used here.

Code prefetching is most valuable in specific scenarios. The first is large route bundles where the JavaScript for a complex page takes a meaningful amount of time to download. For most SvelteKit routes with code splitting, bundles are small enough that the download overhead is negligible, but data-heavy dashboards with many components can have larger chunks. The second is when the route has no load function, or the load function’s data is not predictable, so data prefetching is not useful but getting the code in cache still helps.

The typical pattern is to use preloadCode for routes the user is likely to visit soon but where you cannot predict the specific URL:

<!-- src/routes/blog/+page.svelte -->

<script lang="ts">
	import { preloadCode, goto } from '$app/navigation'
	import { onMount } from 'svelte'

	// Preload the blog post bundle as soon as the listing page mounts,
	// since most users landing on the listing will click a post
	onMount(() => {
		preloadCode('/blog/*')
	})
</script>

How the Prefetch Cache Works

When SvelteKit prefetches a route’s data, it runs the load functions and stores the results in an in-memory cache keyed by URL. The cache is not persisted between page loads; it is a session-level cache that lives as long as the JavaScript runtime is active.

When the user actually navigates to a prefetched URL, SvelteKit checks the cache first. If a fresh result is available, it uses it immediately, making the navigation feel synchronous. If the cache entry is too old or was invalidated, SvelteKit runs the load function again.

The cache duration defaults to the time it takes for a prefetched result to expire before the user navigates. SvelteKit does not cache prefetched data for minutes; it caches it for the brief window between prefetching and navigation. A user who hovers for 200ms, then clicks, gets the cached result. A user who hovers for two minutes, then clicks, probably triggers a fresh load.

How Prefetching Interacts with Dependency Tracking

The prefetch cache respects SvelteKit’s dependency tracking system. If a load function depends on params.slug and you prefetch /blog/post-one, the cached result is keyed to /blog/post-one. Prefetching /blog/post-two is a separate cache entry. This is correct behaviour: the cache is URL-specific.

If a load function declares a custom depends() identifier and that identifier is invalidated before navigation completes, the cached prefetch result is treated as stale and discarded. SvelteKit re-runs the load function on actual navigation. This ensures correctness: you never see prefetched data that a subsequent invalidate() would have cleared.

// src/routes/feed/+page.server.ts

export const load: PageServerLoad = async ({ depends, fetch }) => {
	depends('app:feed')

	const res = await fetch('/api/feed/latest')
	return { items: await res.json() }
}

If this route is prefetched while the user is on another page, and then invalidate('app:feed') is called before the navigation occurs, the prefetched cache is cleared and the load function reruns on navigation. Prefetching is a performance optimisation, not a data consistency bypass.


Common Mistakes and Anti-Patterns

Prefetching all links on a page is tempting but wasteful. A page with twenty links could trigger twenty concurrent load function executions on hover, generating twenty API calls that most of them will never be navigated to. This wastes server resources, can trigger rate limits, and may heat up shared database caches with stale or irrelevant data.

<!-- Avoid: Blanket prefetching on a large list -->

<ul data-sveltekit-preload-data="hover">
	{#each data.allProducts as product}
		<!-- Each of these prefetches on hover — may trigger hundreds of API calls -->
		<a href="/products/{product.id}">{product.name}</a>
	{/each}
</ul>
<!-- Preferred: Prefetch only high-probability navigation targets -->

<ul>
	{#each data.featuredProducts as product}
		<!-- Featured items are likely to be clicked; prefetch them -->
		<a href="/products/{product.id}" data-sveltekit-preload-data="hover">
			{product.name}
		</a>
	{/each}

	{#each data.otherProducts as product}
		<!-- Long tail items: code-only prefetch or none -->
		<a href="/products/{product.id}">
			{product.name}
		</a>
	{/each}
</ul>

Reserve data prefetching for routes with high navigation probability. Use preloadCode for everything else if the bundle size justifies it.

Calling preloadData Inside the Load Function

Prefetching is a browser-side navigation performance tool. Calling preloadData inside a server load function or during SSR will have no effect and may throw an error depending on the environment.

// Wrong: preloadData belongs in component code, not load functions

export const load: PageServerLoad = async ({ params }) => {
	await preloadData(`/blog/${params.slug}/comments`) // ← No effect on the server
	return { post: {} }
}
<!-- Correct: preloadData called in response to user interaction -->

<script lang="ts">
	import { preloadData } from '$app/navigation'
</script>

<a
	href="/blog/{post.slug}/comments"
	onmouseenter={() => preloadData(`/blog/${post.slug}/comments`)}
>
	Read comments
</a>

Ignoring Platform and CDN Interactions

Prefetching makes HTTP requests. If your load functions call APIs protected by rate limiting, generate backend load, or trigger expensive database queries, aggressive prefetching can have real infrastructure costs. Pages that are publicly accessible will also have their load functions run for prefetch requests from unauthenticated or bot-generated traffic if those bots trigger hover events, though this is rare in practice.

If a route’s load function is expensive and users rarely navigate to it from a listing page, prefer preloadCode over preloadData to get the bundle in cache without running the server-side work.


Performance and Scaling Considerations

Hover-based prefetching provides the most benefit on desktop applications with keyboard-accessible navigation. On mobile applications where hover is not meaningful, tap-based prefetching provides a smaller but still real benefit. IntersectionObserver-based prefetching is effective for list-heavy interfaces where the user’s scroll direction indicates intent.

The payoff from prefetching is proportional to your load function latency. If your load functions return in 10ms from an in-memory cache, prefetching gives little additional benefit because the data arrives quickly even without it. If your load functions take 200-500ms because they make external API calls or run complex database queries, prefetching on hover can essentially eliminate that perceived latency for users who follow normal pointing patterns.

Code prefetching with preloadCode has a clearer cost-benefit calculation. The JavaScript bundle for a route is downloaded once and cached by the browser. The cost is bandwidth; the benefit is that future navigations to that route do not need to download the bundle first. For routes with large component trees, route-specific libraries, or editor bundles, this can be meaningful.


Conclusion

Prefetching is one of SvelteKit’s most user-visible performance tools, because it directly affects the time between a user’s click and the page appearing. By starting load functions while the user is still deciding to click, hover-based data prefetching converts idle pointer time into productive network time.

The declarative approach with data-sveltekit-preload-data requires no code changes and is appropriate for most links on most pages. The programmatic preloadData function enables finer control for viewport-based triggers, search interfaces, and explicit intent signals. preloadCode handles the complementary case of warming the JavaScript cache without running load function work.

Used thoughtfully, on high-probability navigation targets with meaningful load function latency, prefetching can make the difference between an application that feels fast and one that feels instant.


Key Takeaways

data-sveltekit-preload-data="hover" on an anchor or parent element triggers data prefetching when the pointer enters the element. SvelteKit applies this to the <body> by default in new projects. tap fires on touchstart or mousedown and is the right trigger for touch-first interfaces.

preloadData(href) from $app/navigation runs a route’s load functions programmatically and caches the result. Use it for viewport-based or intent-based triggers via IntersectionObserver or focus events.

data-sveltekit-preload-code is the declarative equivalent for code-only prefetching. It accepts "eager" (all links on the page immediately), "viewport" (as links enter the viewport), "hover", and "tap". Note that "eager" and "viewport" only apply to links present in the DOM at navigation time, not links added dynamically.

preloadCode(pathname) downloads a route’s JavaScript bundle programmatically without running its load function. Pass a glob pattern such as /blog/* to match dynamic routes, or a concrete URL for a specific page. Do not use the file-system [param] syntax — preloadCode uses glob patterns.

Neither data-sveltekit-preload-data nor preloadData will fire when navigator.connection.saveData is true. SvelteKit automatically respects the user’s reduced-data preference.

The prefetch cache is URL-keyed and session-scoped. It respects depends() declarations: if a custom dependency is invalidated after prefetching and before navigation, the prefetched result is discarded and the load function reruns. Prefetching is always a performance hint, never a data consistency bypass.


What’s Next

Prefetching is the last major performance topic in this series. The final article brings everything together in the context of a topic that depends on nearly every concept covered so far: authentication. The next article, Authentication Patterns in Load Functions, covers why layout-level auth guards are not reliable, how locals and hooks.server.ts form the correct foundation, and how to build a reusable requireLogin() utility that works across any server load function in your application.


Further Reading