The Latency You Did Not Know You Were Paying

Every millisecond your server spends waiting for one fetch before starting another is latency that reaches the user. A page that makes three sequential API calls, each taking 100ms, has a server-to-browser delay of 300ms minimum before a single byte of HTML is sent. The same page with all three calls in parallel delivers its first byte in roughly 100ms. That difference is not theoretical. It is visible on a moderate connection, pronounced on a slow one, and it compounds across every page in your application.

SvelteKit is built with parallelism as the default for its load function execution model. Layout load functions up and down the route hierarchy run concurrently wherever possible. The framework does the right thing automatically as long as you do not accidentally introduce sequencing. The ways you can introduce it are specific and recognizable. Understanding them makes the difference between a fast application and one that is silently slow in ways your development machine will never expose.

This article covers how SvelteKit’s concurrent load execution works, what a request waterfall is and where one tends to hide, the mechanisms that create unintended sequencing including the await parent() trap, strategies to preserve parallelism when parent data is genuinely needed, and a complete parallel dashboard example demonstrating all of the above.


What a Request Waterfall Actually Costs

Request waterfalls are one of the most common and least visible performance problems in web applications. A waterfall occurs when request B cannot start until request A has completed, and request C cannot start until B has completed, and so on. The total time is the sum of all request durations rather than the maximum of any single one.

In a browser, waterfalls are often visible in the network tab as a cascade of requests each starting only after the previous one finishes. On the server, inside a load function, they are invisible from the outside. A user sees a slow page. The developer sees a slow load function. The root cause, three sequential fetches that could have been parallel, is buried in code that looks reasonable at a glance.

The challenge with SvelteKit specifically is that waterfalls can exist at two distinct levels: within a single load function, and across the load function hierarchy. A page with a root layout load, a section layout load, and a page load has three separate execution contexts. If any of them creates a dependency on another that forces sequential execution, the total latency accumulates across the chain.

A waterfall in the load hierarchy:

  Root layout load         [===150ms===]
                                        |
  Section layout load                   [===120ms===]
                                                     |
  Page load                                          [===80ms===]

  Total time: 150 + 120 + 80 = 350ms

  With no dependencies (SvelteKit's default):

  Root layout load         [===150ms===]
  Section layout load      [====120ms====]
  Page load                [==80ms==]

  Total time: max(150, 120, 80) = 150ms

The gap between 350ms and 150ms is entirely architectural. The APIs did not get faster. The network did not improve. The only change is the dependency structure between load functions.


How SvelteKit Runs Load Functions Concurrently

SvelteKit starts all load functions for a given route as soon as possible. For a route like /dashboard, which might involve a root layout load, a dashboard section layout load, and the page load itself, SvelteKit initiates all three simultaneously when a request arrives.

Loading diagram...

None of these load functions waits for the others by default. They are independent. Each makes its own API calls, and SvelteKit merges all their return values into the single data object the page component receives.

This default is what you want. The framework is working for you as long as you do not override it. The mechanisms that override it are all forms of explicit or implicit dependency: one load function expressing that it needs the output of another before it can proceed.

The only way for a load function to express a dependency on another load function’s output is through the parent() function. Calling await parent() is the explicit contract that says: “I cannot proceed until all my ancestor layout loads have completed.” That is sometimes necessary. The problem is when it is called unnecessarily, or called at a point in the function where it blocks work that did not need to wait.


Identifying a Waterfall

The simplest diagnostic for a waterfall is measuring total page load time against what it should theoretically be given the individual API call durations. If your page makes three independent API calls each taking 80ms and the total server time is 240ms, those calls are sequential. If the total is around 80ms, they are parallel.

In development, SvelteKit’s verbose logging is the first tool. In production, a distributed tracing setup attached to your server allows you to see individual fetch timings within a request. Without tracing, adding timestamps inside your load functions manually gives you the data:

// src/routes/dashboard/+page.server.ts
// Temporary diagnostic instrumentation.

import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals, fetch }) => {
	const t0 = Date.now()
	console.log('[dashboard load] started')

	const userResponse = await fetch(`/api/users/${locals.user.id}`)
	console.log(`[dashboard load] user fetched at ${Date.now() - t0}ms`)

	const postsResponse = await fetch(`/api/users/${locals.user.id}/posts`)
	console.log(`[dashboard load] posts fetched at ${Date.now() - t0}ms`)

	const analyticsResponse = await fetch(`/api/users/${locals.user.id}/analytics?period=30d`)
	console.log(`[dashboard load] analytics fetched at ${Date.now() - t0}ms`)

	console.log(`[dashboard load] total: ${Date.now() - t0}ms`)

	return {
		user: await userResponse.json(),
		posts: await postsResponse.json(),
		analytics: await analyticsResponse.json()
	}
}

If the log output looks like this, the calls are sequential:

[dashboard load] started
[dashboard load] user fetched at 87ms
[dashboard load] posts fetched at 174ms
[dashboard load] analytics fetched at 261ms
[dashboard load] total: 263ms

Each fetch starts only after the previous one completes. The await before each fetch() call is the culprit. Fixing it is a matter of restructuring to start all fetches as Promises and then awaiting them together:

[dashboard load] started
[dashboard load] user fetched at 89ms
[dashboard load] posts fetched at 91ms
[dashboard load] analytics fetched at 94ms
[dashboard load] total: 96ms

All three fetches complete within a few milliseconds of each other because they ran in parallel.


Sequential Fetches Within a Single Load Function

The most direct form of waterfall is one you write yourself: using await before calling the next fetch. Every await fetch(...) call blocks execution until that fetch completes. The next line does not execute until the first is done.

// Avoid: three sequential fetches. Total time = sum of all three.
// Each fetch waits for the previous one to complete before starting.

export const load: PageServerLoad = async ({ locals, fetch }) => {
	const userResponse = await fetch(`/api/users/${locals.user.id}`)
	const user = await userResponse.json()

	const postsResponse = await fetch(`/api/users/${locals.user.id}/posts`)
	const posts = await postsResponse.json()

	const analyticsResponse = await fetch(`/api/users/${locals.user.id}/analytics?period=30d`)
	const analytics = await analyticsResponse.json()

	return { user, posts, analytics }
}
// Preferred: start all three fetches simultaneously with Promise.all.
// Total time = max of the three durations.

export const load: PageServerLoad = async ({ locals, fetch }) => {
	const [userResponse, postsResponse, analyticsResponse] = await Promise.all([
		fetch(`/api/users/${locals.user.id}`),
		fetch(`/api/users/${locals.user.id}/posts`),
		fetch(`/api/users/${locals.user.id}/analytics?period=30d`)
	])

	const [user, posts, analytics] = await Promise.all([
		userResponse.json(),
		postsResponse.json(),
		analyticsResponse.json()
	])

	return { user, posts, analytics }
}

Promise.all takes an array of Promises and returns a single Promise that resolves when all of them have resolved. The resolution value is an array of the individual resolved values, in the same order as the input array. If any Promise rejects, Promise.all rejects immediately with that rejection reason — all other results are discarded, even ones that already resolved. This fail-fast behavior is the source of the criticism you may have heard.

The two-stage Promise.all in the preferred example above is deliberate. The first stage starts all three HTTP fetches simultaneously. The second stage reads the response bodies in parallel. Both stages benefit from parallelism.

The pattern also makes the code structure more honest about intent. A sequential await fetch chain implies that each call depends on the previous one. A Promise.all call makes the independence of the operations explicit.

Promise.all vs Promise.allSettled

The criticism of Promise.all is valid in a specific scenario: when some of the fetches are non-critical and a failure in one should not discard the results of the others. For those cases, use Promise.allSettled instead.

Promise.allSettled waits for every Promise regardless of outcome and returns an array of result objects, each with a status of "fulfilled" or "rejected" and the corresponding value or reason.

// Promise.all — use when ALL fetches are critical.
// One failure fails the entire load function.
export const load: PageServerLoad = async ({ locals, fetch }) => {
	const [user, account] = await Promise.all([
		fetch(`/api/users/${locals.user.id}`).then((r) => r.json()),
		fetch(`/api/users/${locals.user.id}/account`).then((r) => r.json())
	])
	// If either fetch fails, the load throws and SvelteKit renders +error.svelte.
	// Correct when you cannot render the page without both values.
	return { user, account }
}
// Promise.allSettled — use when some fetches are optional.
// One failure returns partial data, not an error page.
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ locals, fetch }) => {
	const [userResult, analyticsResult, recommendationsResult] = await Promise.allSettled([
		fetch(`/api/users/${locals.user.id}`).then((r) => r.json()),
		fetch(`/api/users/${locals.user.id}/analytics`).then((r) => r.json()),
		fetch(`/api/recommendations`).then((r) => r.json())
	])

	// User is critical — propagate the error if it failed.
	if (userResult.status === 'rejected') {
		error(500, 'Failed to load user profile')
	}

	// Analytics and recommendations are optional — fall back to null/empty.
	return {
		user: userResult.value,
		analytics: analyticsResult.status === 'fulfilled' ? analyticsResult.value : null,
		recommendations: recommendationsResult.status === 'fulfilled' ? recommendationsResult.value : []
	}
}

The practical rule:

  • Use Promise.all when every fetch in the group is required to render the page — a failure should produce an error page.
  • Use Promise.allSettled when the group contains a mix of critical and optional fetches — you want partial data rather than an error page when non-critical fetches fail.
  • Never use raw Promise.all in a fan-out (teamMembers.map(...)) where individual item failures are expected and should be handled per-item, not as a page-level error.

An alternative: returning unresolved Promises (streaming)

There is a second way to start fetches in parallel: return the Promises directly from the load function without awaiting them. SvelteKit accepts unresolved Promises in the return value and streams data to the component as each one settles.

// +page.ts — return unresolved Promises to stream data as it resolves.
export const load: PageLoad = async ({ fetch }) => {
	return {
		user: fetch('/api/user').then((r) => r.json()), // Promise — not awaited
		posts: fetch('/api/posts').then((r) => r.json()) // Promise — not awaited
	}
}

Both fetches start the moment the return value is evaluated. The load function returns immediately. The page HTML is sent to the browser before either fetch completes. The component receives the Promises and must handle a pending state — typically with {#await} - until they resolve.

Use Promise.all (await everything) when:

  • The data is required to render the page at all — a post title, a user name, anything that would leave a blank or broken UI if absent on first paint.
  • You are in a +page.server.ts — server load functions do not support streaming; only universal +page.ts loads can return unresolved Promises.
  • The component has no meaningful loading state to show — a spinner for every field is worse UX than a short wait for complete data.

Use returning Promises (streaming) when:

  • The data is secondary or non-critical — analytics panels, related content, comment counts — things the user does not need to start reading the page.
  • The primary content is ready quickly and the secondary data is slower; holding the response for the slow fetch would delay everything unnecessarily.
  • You are willing to write and maintain the {#await} loading states in the component.

In practice, Promise.all is the right default for most load functions. Streaming is a deliberate progressive enhancement for specific slow, non-critical data that would otherwise block an otherwise-fast page. Reaching for streaming to avoid thinking about Promise.all is the wrong motivation — both patterns start fetches in parallel; the only difference is when the browser receives the HTML.

The streaming pattern and its component-side {#await} handling are covered in full in Streaming Data with Promises.

When sequential fetches are genuinely necessary

Not all sequential fetches are avoidable. When the URL or parameters of a second fetch depend on the result of the first, the dependency is real and the sequencing is unavoidable.

// This sequencing is necessary. The second fetch uses data from the first.
// No restructuring eliminates this dependency.

export const load: PageServerLoad = async ({ params, fetch }) => {
	// Fetch a post to get its author's ID.
	const postResponse = await fetch(`/api/posts/${params.slug}`)
	const post = await postResponse.json()

	// Fetch the author's full profile using the ID from the post.
	// This cannot start until the post is loaded.
	const authorResponse = await fetch(`/api/users/${post.authorId}/profile`)
	const author = await authorResponse.json()

	return { post, author }
}

This is a legitimate waterfall. You cannot fetch the author without knowing the author’s ID, and you cannot know the author’s ID without fetching the post. The correct response is to accept the sequential cost and minimize everything else. If you need additional independent data (say, related posts and site configuration), fetch those in parallel with the second fetch, not after it:

// Minimize the waterfall cost by parallelizing independent work
// at each stage of the dependency chain.

export const load: PageServerLoad = async ({ params, fetch }) => {
	// Stage 1: Fetch the post. This must complete before stage 2 can start.
	const postResponse = await fetch(`/api/posts/${params.slug}`)
	const post = await postResponse.json()

	// Stage 2: Start everything that depends on the post in parallel.
	// These are independent of each other even though they depend on the post.
	const [authorResponse, relatedResponse] = await Promise.all([
		fetch(`/api/users/${post.authorId}/profile`),
		fetch(`/api/posts/${params.slug}/related?limit=3`)
	])

	const [author, related] = await Promise.all([
		authorResponse.json(),
		relatedResponse.ok ? relatedResponse.json() : Promise.resolve([])
	])

	return { post, author, related }
}

The total time is now post_fetch + max(author_fetch, related_fetch) rather than post_fetch + author_fetch + related_fetch. The genuine dependency (post before author) is respected. The unnecessary sequencing (related after author) is eliminated.


The await parent() Trap in the Load Hierarchy

Using Parent Data in Load Functions covers the mechanics, timing diagrams, and fix for this trap in depth. The short version: calling await parent() at the top of a load function blocks every fetch below it until all ancestor layout loads complete, even if those fetches have no dependency on ancestor data. Total page time becomes ancestor_duration + page_fetch_duration instead of max(ancestor_duration, page_fetch_duration).

The fix: start your fetches before awaiting parent

Start every fetch that does not require parent data before calling await parent(). By the time parent() resolves, those fetches are likely already complete.

// Avoid: parent() awaited at the top before any page-level work starts.
// The page load's fetches cannot begin until all ancestor loads complete.

export const load: PageServerLoad = async ({ params, parent, fetch }) => {
	const { user, siteConfig } = await parent()

	// These fetches are independent of parent data but are now blocked
	// behind the entire ancestor load chain.
	const postsResponse = await fetch(`/api/posts?tag=${params.tag}`)
	const statsResponse = await fetch('/api/stats/summary')

	return {
		posts: await postsResponse.json(),
		stats: await statsResponse.json(),
		siteName: siteConfig.siteName
	}
}
// Preferred: start independent fetches before awaiting parent().
// The page-level network work runs concurrently with ancestor loads.

export const load: PageServerLoad = async ({ params, parent, fetch }) => {
	// Start page-level fetches immediately. They do not depend on parent data.
	const postsPromise = fetch(`/api/posts?tag=${params.tag}`)
	const statsPromise = fetch('/api/stats/summary')

	// Await parent() while the fetches above are already in flight.
	// If ancestor loads are slow, the page-level fetches complete while waiting.
	const { siteConfig } = await parent()

	// Await both responses. If they completed while parent() was resolving,
	// these awaits resolve immediately at no additional cost.
	const [postsResponse, statsResponse] = await Promise.all([postsPromise, statsPromise])

	return {
		posts: postsResponse.ok ? await postsResponse.json() : [],
		stats: statsResponse.ok ? await statsResponse.json() : null,
		siteName: siteConfig.siteName
	}
}

When parent() must precede the fetch

When a fetch URL depends on parent data — a user ID, tenant identifier, or access token — the dependency is real and sequencing is unavoidable. Using Parent Data in Load Functions covers this pattern in full, including how to minimize cost by parallelizing everything that does not share the dependency once parent() resolves.


Waterfall Patterns Across the Layout Hierarchy

Individual load functions are not the only place waterfalls appear. A common pattern is a layout hierarchy where each level awaits parent data before doing its own work, creating a chain that serializes every load from root to page.

Consider a three-level hierarchy:

// Avoid: the root layout loads slowly and everything chains behind it.

// src/routes/+layout.server.ts
export const load: LayoutServerLoad = async ({ fetch }) => {
	// Slow: 200ms
	const configResponse = await fetch('/api/site/config')
	return { siteConfig: await configResponse.json() }
}

// src/routes/app/+layout.server.ts
export const load: LayoutServerLoad = async ({ parent, fetch }) => {
	// Awaits root layout before doing anything.
	const { siteConfig } = await parent()

	// Does not use siteConfig at all in its fetch. But it waits anyway.
	const navResponse = await fetch('/api/navigation')
	return { navItems: await navResponse.json() }
}

// src/routes/app/dashboard/+page.server.ts
export const load: PageServerLoad = async ({ parent, fetch }) => {
	// Awaits the full hierarchy. By the time this starts its fetch,
	// the root and section layouts have both completed.
	const { siteConfig, navItems } = await parent()

	const dashResponse = await fetch('/api/dashboard/summary')
	return { dashboard: await dashResponse.json() }
}

The execution timeline for this hierarchy:

Waterfall across all three levels:

  Root layout (200ms)   [========200ms========]
  App layout (90ms)     waiting......[==90ms==]
  Page load (110ms)     waiting.............[===110ms===]

  Total: 200 + 90 + 110 = 400ms

The fix requires examining each level and asking: does this load function actually need the parent data before it starts its own work?

// Preferred: each level starts its own work immediately.
// parent() is called only when the value is genuinely needed for a merge.

// src/routes/+layout.server.ts
export const load: LayoutServerLoad = async ({ fetch }) => {
	const configResponse = await fetch('/api/site/config')
	return { siteConfig: await configResponse.json() }
}

// src/routes/app/+layout.server.ts
export const load: LayoutServerLoad = async ({ fetch }) => {
	// This load does not need any root layout data to fetch navigation.
	// Do not call parent() at all. Navigation is independent.
	const navResponse = await fetch('/api/navigation')
	return { navItems: await navResponse.json() }
}

// src/routes/app/dashboard/+page.server.ts
export const load: PageServerLoad = async ({ parent, fetch }) => {
	// Start the dashboard fetch immediately.
	const dashPromise = fetch('/api/dashboard/summary')

	// Await parent only when needed for the return value.
	// parent() resolves when both ancestor loads are done.
	const { siteConfig } = await parent()

	const dashResponse = await dashPromise
	return {
		dashboard: dashResponse.ok ? await dashResponse.json() : null,
		analyticsKey: siteConfig.analyticsKey
	}
}

The execution timeline for the fixed hierarchy:

Parallel execution with no unnecessary dependencies:

  Root layout (200ms)       [========200ms========]
  App layout (90ms)         [==90ms==]
  dashboard fetch (110ms)   [====110ms====]
  await parent()            .............. resolves at 200ms
  dashResponse.json()       resolves immediately (done at 110ms)

  Total: max(200, 90, 110) = 200ms

The total time collapsed from 400ms to 200ms. The bottleneck is now the single slowest operation, which is the correct outcome.

The key insight from this comparison is that removing parent() from the app layout was the highest-value change. That layout was calling await parent() at the top and then not using the parent data in its fetch at all. The data from parent was only needed as a pass-through to the component. Since SvelteKit merges all load outputs automatically, the page component receives siteConfig without the app layout having to do anything with it.


Measuring the Dependency Graph

When debugging performance in a multi-level layout hierarchy, building a mental model of the dependency graph helps identify where parallelism is being sacrificed. The rules are:

A load function that calls no parent() runs in parallel with all other load functions for the same route. A load function that calls await parent() at the top runs sequentially after all ancestor layout loads. A load function that calls await parent() after starting its own fetches runs concurrently with ancestor loads but blocks the merge step until parent() resolves.

Loading diagram...

The merge step waits for the last parent() to resolve and for the page load’s own data to be ready. In this graph, those are the same event at 200ms.


Promise.all for Independent Fetches Within a Load

The Promise.all pattern within a single load function deserves dedicated treatment because it covers more cases than the simple “parallelize three fetches” example. Any group of operations that are independent of each other and produce results you need before returning should be combined into a Promise.all.

Multi-stage parallel fetches

When a load function fetches multiple collections and each item in those collections requires a detail fetch, the inner fetches can be parallelized using Promise.all mapped over an array:

// src/routes/app/team/+page.server.ts

import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ locals, fetch }) => {
	// Fetch the team member list first.
	const teamResponse = await fetch(`/api/teams/${locals.user.teamId}/members`)

	if (!teamResponse.ok) {
		error(500, 'Failed to load team members')
	}

	const teamMembers: Array<{ id: string; name: string }> = await teamResponse.json()

	// For each team member, fetch their activity summary in parallel.
	// Promise.all over an array of fetches is the idiomatic pattern.
	// All member activity fetches start simultaneously.
	const memberActivityPromises = teamMembers.map((member) =>
		fetch(`/api/users/${member.id}/activity/summary`)
			.then((r) => (r.ok ? r.json() : null))
			.catch(() => null)
	)

	const memberActivities = await Promise.all(memberActivityPromises)

	// Merge the activity data back onto each member.
	const enrichedMembers = teamMembers.map((member, index) => ({
		...member,
		activity: memberActivities[index]
	}))

	return { members: enrichedMembers }
}

The total time for this pattern is teamList_fetch + max(all_member_activity_fetches) rather than teamList_fetch + sum(all_member_activity_fetches). For a team of ten members each with a 60ms activity endpoint, that is 60ms versus 600ms for the second stage.

This pattern has a scaling consideration: Promise.all with a very large array creates a large number of simultaneous requests against the downstream API. An array of 200 members would initiate 200 concurrent fetch calls. Most APIs and internal services have rate limits or connection pool limits that will reject or queue this load. For large collections, consider batching: split the array into groups and Promise.all within each group, sequencing the groups.

// Batched parallel fetches for large collections.
// Prevents overwhelming downstream APIs with too many simultaneous requests.

async function fetchInBatches<T, R>(
	items: T[],
	batchSize: number,
	fetchFn: (item: T) => Promise<R>
): Promise<R[]> {
	const results: R[] = []

	for (let i = 0; i < items.length; i += batchSize) {
		const batch = items.slice(i, i + batchSize)
		const batchResults = await Promise.all(batch.map(fetchFn))
		results.push(...batchResults)
	}

	return results
}
// Usage in a load function with large collections.
const memberActivities = await fetchInBatches(
	teamMembers,
	10, // Process 10 members at a time
	(member) =>
		fetch(`/api/users/${member.id}/activity/summary`)
			.then((r) => (r.ok ? r.json() : null))
			.catch(() => null)
)

Combining Promise.all with conditional fetches

Sometimes a fetch should only happen if a condition is met. Combining conditional fetches with Promise.all requires care to maintain the correct array index alignment:

// src/routes/app/profile/+page.server.ts

import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals, url, fetch }) => {
	const showBilling = url.searchParams.get('tab') === 'billing'
	const showIntegrations = url.searchParams.get('tab') === 'integrations'

	// Build the array of Promises conditionally.
	// Use null as the resolved value for skipped fetches.
	const [profileResponse, billingResponse, integrationsResponse] = await Promise.all([
		fetch(`/api/users/${locals.user.id}/profile`),
		showBilling ? fetch(`/api/users/${locals.user.id}/billing`) : Promise.resolve(null),
		showIntegrations ? fetch(`/api/users/${locals.user.id}/integrations`) : Promise.resolve(null)
	])

	return {
		profile: profileResponse.ok ? await profileResponse.json() : null,
		billing: billingResponse && billingResponse.ok ? await billingResponse.json() : null,
		integrations:
			integrationsResponse && integrationsResponse.ok ? await integrationsResponse.json() : null
	}
}

Promise.resolve(null) is a Promise that resolves immediately to null. It can be placed in a Promise.all array alongside real fetch Promises without affecting timing. The skipped fetch contributes no latency. The array index alignment is maintained, so the destructuring assignment stays correct.


Practical Example: A Parallel Dashboard

The following complete example demonstrates all of the patterns from this article in a realistic context. The dashboard route has three levels of load function. Each is optimized for parallelism. No level awaits parent data it does not need. The page load defers its parent() call until after its own independent fetches are in flight.

Type definitions

// src/lib/types/dashboard.ts

export interface User {
	id: string
	name: string
	email: string
	plan: 'free' | 'pro' | 'enterprise'
	avatarUrl: string
}

export interface SiteConfig {
	siteName: string
	supportEmail: string
	analyticsKey: string
}

export interface Post {
	id: string
	title: string
	slug: string
	publishedAt: string
	viewCount: number
	status: 'draft' | 'published' | 'archived'
}

export interface AnalyticsSummary {
	period: string
	totalViews: number
	totalReads: number
	averageReadTime: number
	topPosts: Array<{ postId: string; title: string; views: number }>
}

export interface NavItem {
	label: string
	href: string
	icon: string
}

The root layout: site configuration

// src/routes/+layout.server.ts
// Fetches site-wide configuration.
// No parent() call. Runs in parallel with everything else.

import type { LayoutServerLoad } from './$types'
import type { SiteConfig } from '$lib/types/dashboard'

export const load: LayoutServerLoad = async ({ fetch }) => {
	const response = await fetch('/api/site/config')

	const siteConfig: SiteConfig = response.ok
		? await response.json()
		: {
				siteName: 'The Hackpile Chronicles',
				supportEmail: 'support@hackpile.dev',
				analyticsKey: ''
			}

	return { siteConfig }
}

The app section layout: navigation

// src/routes/app/+layout.server.ts
// Fetches navigation items for the authenticated app shell.
// Validates the session via locals — no network call needed here.
// No parent() call. Runs in parallel with all other loads.

import type { LayoutServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'
import type { NavItem } from '$lib/types/dashboard'

export const load: LayoutServerLoad = async ({ locals, fetch }) => {
	if (!locals.user) {
		redirect(303, '/login')
	}

	const response = await fetch('/api/navigation/app')

	const navItems: NavItem[] = response.ok ? await response.json() : []

	return { navItems }
}

The dashboard page load: user, posts, and analytics in parallel

// src/routes/app/dashboard/+page.server.ts
// Loads the dashboard-specific data.
//
// Execution model:
//   - userPromise, postsPromise, analyticsPromise start at t=0
//   - parent() is awaited after the fetches are in flight
//   - parent() resolves when both ancestor layout loads complete
//   - The three fetch Promises likely resolve before or during parent()
//   - The page load's total time = max(ancestor loads, own fetch durations)

import type { PageServerLoad } from './$types'
import { error, redirect } from '@sveltejs/kit'
import type { User, Post, AnalyticsSummary } from '$lib/types/dashboard'

export const load: PageServerLoad = async ({ locals, parent, fetch }) => {
	if (!locals.user) {
		redirect(303, '/login?returnTo=/app/dashboard')
	}

	// Start all three dashboard fetches immediately.
	// None of them depend on parent() data.
	// All three are in flight before parent() is called.
	const userPromise = fetch(`/api/users/${locals.user.id}`)
	const postsPromise = fetch(`/api/users/${locals.user.id}/posts?status=published&limit=5`)
	const analyticsPromise = fetch(`/api/users/${locals.user.id}/analytics?period=30d`)

	// Await parent() while the three fetches are running.
	// This blocks here until both ancestor layout loads resolve.
	// The ancestor loads are running concurrently with the fetches above.
	const { siteConfig } = await parent()

	// Await all three fetch responses together.
	// If they completed while parent() was resolving, these await immediately.
	const [userResponse, postsResponse, analyticsResponse] = await Promise.all([
		userPromise,
		postsPromise,
		analyticsPromise
	])

	// User data is critical. If it fails, error() stops the load function.
	if (!userResponse.ok) {
		error(500, 'Failed to load user profile')
	}

	const user: User = await userResponse.json()

	// Posts and analytics are important but not critical.
	// Return empty fallbacks rather than erroring if they fail.
	const [posts, analytics]: [Post[], AnalyticsSummary | null] = await Promise.all([
		postsResponse.ok ? postsResponse.json() : Promise.resolve([]),
		analyticsResponse.ok ? analyticsResponse.json() : Promise.resolve(null)
	])

	return {
		user,
		posts,
		analytics,
		// analyticsKey from siteConfig is needed to initialise the
		// client-side analytics tracker in the component.
		analyticsKey: siteConfig.analyticsKey
	}
}

The dashboard component

<!-- src/routes/app/dashboard/+page.svelte -->

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

	let { data }: PageProps = $props()

	const publishedPostCount = $derived(data.posts.filter((p) => p.status === 'published').length)

	const topPost = $derived(data.analytics?.topPosts?.[0] ?? null)

	const formattedViews = $derived(
		data.analytics ? new Intl.NumberFormat('en-US').format(data.analytics.totalViews) : ''
	)

	$effect(() => {
		if (data.analyticsKey) {
			;(window as Window & { __analyticsKey?: string }).__analyticsKey = data.analyticsKey
		}
	})
</script>

<div class="dashboard">
	<header class="dashboard-header">
		<img src={data.user.avatarUrl} alt={data.user.name} class="user-avatar" />
		<div class="user-info">
			<h1>Welcome back, {data.user.name}</h1>
			<p class="user-plan">{data.user.plan} plan</p>
		</div>
	</header>

	<div class="dashboard-grid">
		<section class="stat-card">
			<h2>Published Posts</h2>
			<p class="stat-value">{publishedPostCount}</p>
		</section>

		<section class="stat-card">
			<h2>Total Views (30d)</h2>
			<p class="stat-value">{formattedViews}</p>
		</section>

		{#if data.analytics}
			<section class="stat-card">
				<h2>Avg Read Time</h2>
				<p class="stat-value">{data.analytics.averageReadTime}s</p>
			</section>
		{/if}

		{#if topPost}
			<section class="stat-card highlight">
				<h2>Top Post (30d)</h2>
				<p class="stat-value truncate">{topPost.title}</p>
				<p class="stat-sub">{new Intl.NumberFormat('en-US').format(topPost.views)} views</p>
			</section>
		{/if}
	</div>

	<section class="recent-posts">
		<h2>Recent Posts</h2>
		{#if data.posts.length === 0}
			<p class="empty-state">No published posts yet.</p>
		{:else}
			<ul class="post-list">
				{#each data.posts as post (post.id)}
					<li class="post-item">
						<a href="/app/posts/{post.slug}" class="post-title">
							{post.title}
						</a>
						<span class="post-views">
							{new Intl.NumberFormat('en-US').format(post.viewCount)} views
						</span>
						<time datetime={post.publishedAt} class="post-date">
							{new Date(post.publishedAt).toLocaleDateString('en-US', {
								year: 'numeric',
								month: 'short',
								day: 'numeric'
							})}
						</time>
					</li>
				{/each}
			</ul>
		{/if}
	</section>
</div>

<!-- The analyticsKey is set on window via $effect in the script block above.
     This runs on the client after mount, using the value from siteConfig
     passed through parent() at the merge step. -->

The app layout component

<!-- src/routes/app/+layout.svelte -->

<script lang="ts">
	import type { LayoutProps } from './$types'

	let { data, children }: LayoutProps = $props()
</script>

<div class="app-shell">
	<nav class="app-nav">
		<span class="app-nav-brand">{data.siteConfig.siteName}</span>

		<ul class="app-nav-links">
			{#each data.navItems as item (item.href)}
				<li>
					<a href={item.href} class="app-nav-link">
						{item.label}
					</a>
				</li>
			{/each}
		</ul>
	</nav>

	<main class="app-main">
		{@render children()}
	</main>
</div>

Annotated execution timeline for the full dashboard

Request arrives at GET /app/dashboard

t=0ms
  Root layout load starts:    fetch('/api/site/config')
  App layout load starts:     locals.user check passes, fetch('/api/navigation/app')
  Page load starts:           locals.user check passes
                              fetch('/api/users/123')           <- userPromise
                              fetch('/api/users/123/posts')     <- postsPromise
                              fetch('/api/users/123/analytics') <- analyticsPromise
                              await parent() called

t=70ms   analyticsPromise resolves (fastest fetch)
t=85ms   postsPromise resolves
t=90ms   navItems response resolves  ->  app layout load returns { navItems }
t=100ms  userPromise resolves
t=110ms  siteConfig response resolves  ->  root layout load returns { siteConfig }

t=110ms  parent() in page load resolves (both ancestor loads done)
         Promise.all([userPromise, postsPromise, analyticsPromise]) resolves immediately
         (all three were done before parent() resolved)
         user, posts, analytics parsed
         page load returns { user, posts, analytics, analyticsKey }

t=110ms  SvelteKit merges all three load outputs
         HTML rendered and sent to browser

Total server time: 110ms  (determined by root layout config fetch)
Without parallel optimization: 110 + 90 + max(100, 85, 70) = 300ms

The savings here are substantial: 110ms versus 300ms. The critical path is the root layout’s site configuration fetch. Everything else completes before or alongside it. The page load’s three fetches all complete before parent() resolves, so they add zero additional latency.


Common Mistakes and Anti-Patterns

Calling parent() at the top without checking if it blocks fetches

// Avoid: pattern where parent() is called at the top without examining
// whether any of the fetches below actually need its value.
export const load: PageServerLoad = async ({ params, parent, fetch }) => {
	const { user, siteConfig, navItems } = await parent()

	// Does this fetch actually use user, siteConfig, or navItems?
	// If not, it was unnecessarily blocked behind the ancestor chain.
	const response = await fetch(`/api/posts/${params.slug}`)
	return { post: await response.json() }
}
// Preferred: examine each fetch to determine if it truly depends on parent data.
// If none of the fetches need parent data, consider not calling parent() at all
// and relying on SvelteKit's automatic merge to provide ancestor data to the component.
export const load: PageServerLoad = async ({ params, parent, fetch }) => {
	// This fetch does not depend on parent data.
	const postPromise = fetch(`/api/posts/${params.slug}`)

	// If parent data is needed in the return value, await it after starting fetches.
	const { siteConfig } = await parent()

	const postResponse = await postPromise
	return {
		post: postResponse.ok ? await postResponse.json() : null,
		analyticsKey: siteConfig.analyticsKey
	}
}

Sequential Promise.all stages when they can be merged

// Avoid: two separate Promise.all calls where one could serve both purposes.
// The response and the parsed body can be handled in a single chain.
export const load: PageServerLoad = async ({ locals, fetch }) => {
	const [userRes, postsRes] = await Promise.all([
		fetch(`/api/users/${locals.user.id}`),
		fetch(`/api/users/${locals.user.id}/posts`)
	])

	// This second Promise.all is necessary for the .json() calls,
	// but both .json() reads could have been chained onto the first Promise.all.
	const [user, posts] = await Promise.all([userRes.json(), postsRes.json()])

	return { user, posts }
}
// Preferred: chain the .json() parse directly into the fetch Promise.
// Both the HTTP request and the body parse run in parallel per resource,
// and the two resources run in parallel with each other.
// A single await covers both stages.
export const load: PageServerLoad = async ({ locals, fetch }) => {
	const [user, posts] = await Promise.all([
		fetch(`/api/users/${locals.user.id}`).then((r) => r.json()),
		fetch(`/api/users/${locals.user.id}/posts`).then((r) => (r.ok ? r.json() : []))
	])

	return { user, posts }
}

This is a minor optimization but worth understanding. The two-Promise.all version introduces a tiny sequential gap between the response arriving and the body being parsed. In practice the gap is negligible, but the chained version is also cleaner and makes the intent more readable.

Calling parent() only to re-forward data components already receive

SvelteKit automatically merges all load function outputs into the data prop. A layout does not need to call parent() and re-return ancestor values for downstream components to access them — the component already receives them. Calling parent() for this purpose only creates an unnecessary dependency on ancestor load completion. The full explanation and alternatives are in Using Parent Data in Load Functions: When NOT to Use This Pattern.


Performance and Scaling Considerations

The gains from eliminating waterfalls scale with traffic. On a lightly loaded server, a 200ms sequential load versus a 90ms parallel load is a 110ms improvement per user. Under 1000 concurrent requests, the parallel version handles requests with lower latency and keeps fewer connections open simultaneously, because each connection is held open for less time. The throughput of the server increases as a direct consequence of shorter request durations.

The relationship between parallelism and downstream API load is worth considering explicitly. Parallelizing fetches within a load function means the downstream APIs receive requests in a burst rather than sequentially. For a single user, this is fine. For a high-traffic page where the same load function runs thousands of times per second, each instance parallelizing three fetches against the same downstream API means that API receives three times the request rate compared to a sequential version. Ensure downstream services are sized for burst traffic from your parallel load functions.

The use of Promise.all for array-mapped fetches (the team member activity example) requires particular attention. Fetching activity for 50 team members sends 50 simultaneous requests against the activity API. Consider whether the activity API is designed for that access pattern. Internal services designed for single-record access do not always handle fan-out well. Batch endpoints, where you send a list of IDs and receive a list of results, are preferable for this pattern when the downstream service supports them.


What’s Next

The next article in this series covers exactly that decision framework: Rerunning Load Functions and Dependency Tracking explains how SvelteKit tracks what each load function accessed, when it decides to re-run after navigation, and how to use invalidate(), invalidateAll(), depends(), and untrack() to keep data fresh without unnecessary refetching.


See Also

  • Using Parent Data in Load Functions — the mechanics article on parent(): what it returns, the exact scope difference between server and universal loads, and the fetch-first pattern with detailed timing analysis
  • Streaming Data with Promises — complements this article by covering how to defer secondary data through unresolved Promises rather than blocking the initial HTML on it
  • Fetch in Load Functions — covers the details of SvelteKit’s built-in fetch wrapper, including how it is different from the global fetch and why the distinction matters in server load functions
  • SvelteKit Load Documentation — official reference for load functions including the concurrent execution model and the dependency rules that govern when loads are re-run