The Problem with Waiting for Everything
Consider a blog post page. The post itself is the reason the user came to the page. They need the title, the author, the body. The comments section, the related articles sidebar, the “readers also liked” panel at the bottom: those are supplementary. A user would rather see the article immediately and wait a moment for the comments to populate than stare at a blank or loading screen while the slowest of four API calls completes.
The default behavior of a SvelteKit server load function is to await everything before sending any HTML to the browser. If your load function calls four APIs and the slowest takes 800ms, the browser sees nothing for 800ms. The page feels slow even if the critical content was ready in 120ms.
Streaming is the mechanism for breaking that coupling. A server load function can return some data resolved and some data as an unresolved Promise. SvelteKit sends the initial HTML immediately, using the resolved data to render the page shell, and then streams the remaining data to the browser as the Promises resolve. The component handles the pending state with {#await} blocks.
This article covers how streaming works in SvelteKit, the precise mechanics of unresolved Promises in load function return values, how to structure components to handle each state correctly, how to prevent Promise rejections from crashing the server, which platforms support streaming and which silently buffer it away, and the conditions under which streaming is the wrong tool entirely.
The Problem Space
The standard load function pattern in previous articles in this series awaits everything before returning:
// src/routes/blog/[slug]/+page.server.ts
// Standard pattern: nothing reaches the browser until all four fetches complete.
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, fetch }) => {
const [postResponse, commentsResponse, relatedResponse, analyticsResponse] = await Promise.all([
fetch(`/api/blog/posts/${params.slug}`),
fetch(`/api/blog/posts/${params.slug}/comments`),
fetch(`/api/blog/posts/${params.slug}/related`),
fetch(`/api/analytics/post-views/${params.slug}`)
])
if (!postResponse.ok) {
error(404, 'Post not found')
}
const [post, comments, related, analytics] = await Promise.all([
postResponse.json(),
commentsResponse.ok ? commentsResponse.json() : Promise.resolve([]),
relatedResponse.ok ? relatedResponse.json() : Promise.resolve([]),
analyticsResponse.ok ? analyticsResponse.json() : Promise.resolve(null)
])
return { post, comments, related, analytics }
} This is good code. It parallelizes the fetches and handles failures gracefully. But its total server response time is determined by the slowest of the four fetches. If commentsResponse takes 600ms because comments are stored in a different service with a cold cache, every user waits 600ms before seeing any content, including the post itself which was ready in 90ms.
The problem is not that the requests run serially. They run in parallel with Promise.all. The problem is that everything waits at the serialization boundary: the point where SvelteKit takes the load function’s return value, serializes it into the initial HTML response, and sends it to the browser. That boundary requires all values to be resolved.
Streaming removes that constraint for data that is not needed for the initial render.
How Streaming Works in SvelteKit
SvelteKit’s streaming model rests on a straightforward premise: if a value in the load function’s return object is an unresolved Promise, SvelteKit does not wait for it. It sends the initial HTML immediately with the resolved values in place, and then streams each Promise’s resolved value to the browser over the same HTTP connection as it settles.
The mechanism uses HTTP chunked transfer encoding, the same mechanism that allows servers to send response bodies in pieces without knowing the total length upfront. SvelteKit writes the initial HTML as the first chunk, then writes additional script tags containing each resolved Promise value as subsequent chunks when they arrive. The browser executes those script tags as they are received, updating the page without a full navigation.
The browser experience is progressive. The user sees the post immediately. The related articles sidebar populates a moment later. The comments section fills in last. Each section transitions from its loading state to its populated state independently. Total time to the page being fully interactive is still determined by the slowest fetch, but time to the page being useful is determined by the fastest critical fetch.
Returning Unresolved Promises from Load Functions
The syntax for streaming in a server load function is direct: return a Promise instead of an awaited value. Do not await the Promise before returning it.
// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, fetch }) => {
// The post fetch is awaited. This data is critical.
// SvelteKit will not send any HTML until this resolves.
// If the post does not exist, error() terminates the load function
// before any streaming begins.
const postResponse = await fetch(`/api/blog/posts/${params.slug}`)
if (!postResponse.ok) {
error(postResponse.status === 404 ? 404 : 500, 'Post not found')
}
const post = await postResponse.json()
// The comments and related fetches are NOT awaited before being returned.
// They are returned as Promises. SvelteKit will stream their results.
// The component handles the pending state with {#await}.
const comments = fetch(`/api/blog/posts/${params.slug}/comments`).then((response) =>
response.ok ? response.json() : Promise.resolve([])
)
const related = fetch(`/api/blog/posts/${params.slug}/related?limit=3`).then((response) =>
response.ok ? response.json() : Promise.resolve([])
)
return {
post, // resolved value: rendered in initial HTML
comments, // Promise: streamed when it settles
related // Promise: streamed when it settles
}
} The return type of comments and related here is Promise<SomeType[]>. SvelteKit accepts Promises in load function return objects from server load functions, and this is what activates streaming. The TypeScript types generated in ./$types reflect this: data.comments in the component will be typed as Promise<Comment[]>, not Comment[].
The critical invariant to understand: the initial HTML is sent as soon as all non-Promise values in the return object are resolved. The load function does not complete in the traditional sense before the first HTML reaches the browser. SvelteKit begins the response early and the streaming data is appended to the same response over time.
The Ordering Trick: Critical Data First
The correct structure for a streaming load function follows a clear ordering principle. Fetch and await the data that the page cannot render without. Return the rest as Promises. The division between critical and deferred is not arbitrary. It reflects the question: what must be on screen for the page to be useful at all?
For a blog post page, the post itself is critical. The URL maps to this specific post, and if it does not exist or cannot be loaded, the page should show an error, not a skeleton. Comments and related articles are supplementary. The page is useful without them.
// src/routes/blog/[slug]/+page.server.ts
// Full implementation with correct ordering.
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
import type { BlogPost, Comment, RelatedArticle } from '$lib/types/blog'
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
// Phase 1: Critical data. Await this before anything else.
// If this fails, error() stops execution and no HTML is sent.
const postResponse = await fetch(`/api/blog/posts/${params.slug}`)
if (postResponse.status === 404) {
error(404, `The article "${params.slug}" does not exist.`)
}
if (!postResponse.ok) {
error(500, 'Failed to load article.')
}
const post: BlogPost = await postResponse.json()
// Phase 2: Start deferred fetches immediately after the critical data resolves.
// These run in parallel on the server. The browser receives the initial HTML
// while these are still in flight. Each resolves and is streamed independently.
// Attach the error fallback inside the Promise chain here, not in the component.
// This ensures the component always receives the same shape of data
// regardless of whether the fetch succeeded or failed.
const comments: Promise<Comment[]> = fetch(`/api/blog/posts/${params.slug}/comments`)
.then((response) => {
if (!response.ok) return []
return response.json()
})
.catch(() => [])
const related: Promise<RelatedArticle[]> = fetch(`/api/blog/posts/${params.slug}/related?limit=3`)
.then((response) => {
if (!response.ok) return []
return response.json()
})
.catch(() => [])
// Phase 3: Return immediately.
// 'post' is a resolved value. It becomes part of the initial HTML.
// 'comments' and 'related' are Promises. They stream when they settle.
return {
post,
comments,
related
}
} The timing of this structure matters precisely. The deferred fetches start in Phase 2, immediately after the critical data resolves. They do not start after the return. By the time SvelteKit begins writing the initial HTML chunk, the deferred fetches are already in flight on the server. The gap between “initial HTML sent” and “deferred data arrives” is only as long as the deferred fetches take, not as long as those fetches plus the time to send the initial HTML.
If the deferred fetches were started lazily, the streaming benefit would be reduced. Starting them before the return ensures they are running concurrently with the network transmission of the initial HTML.
Handling Streaming States in Components
The component counterpart to a streamed Promise is the {#await} block. Svelte’s {#await} block handles three states:
- Pending state - while the Promise is unresolved
- Success state - with the resolved value
- Error state - if the Promise rejects.
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
import type { PageProps } from './$types'
let { data }: PageProps = $props()
</script>
<article class="post">
<!-- Critical data: rendered in initial HTML, always available synchronously. -->
<header>
<h1>{data.post.title}</h1>
<p class="byline">By {data.post.author.name}</p>
<time datetime={data.post.publishedAt}>{data.post.publishedAt}</time>
</header>
<!-- post.body is sanitized server-side before storage -->
<div class="post-body">{@html data.post.body}</div>
<!-- Deferred data: data.comments is a Promise.
The three states of {#await} give users a clear progressive experience. -->
<section class="comments">
<h2>Comments</h2>
{#await data.comments}
<!-- Pending state: shown while the Promise is unresolved.
Use skeleton UI rather than a spinner for content-shaped loading states.
The user can read the article while this resolves. -->
<ul class="comment-list skeleton" aria-label="Loading comments">
{#each [1, 2, 3] as n (n)}
<li class="comment skeleton-row"></li>
{/each}
</ul>
{:then comments}
<!-- Success state: the Promise resolved with the comments array. -->
{#if comments.length === 0}
<p class="no-comments">No comments yet. Be the first to respond.</p>
{:else}
<ul class="comment-list">
{#each comments as comment (comment.id)}
<li class="comment">
<strong class="comment-author">{comment.author}</strong>
<p class="comment-body">{comment.body}</p>
<time datetime={comment.createdAt}>{comment.createdAt}</time>
</li>
{/each}
</ul>
{/if}
{:catch}
<!-- Error state: shown if the Promise rejects.
This should only appear if the load function's .catch() fallback
was not applied, or if you intentionally allow rejections. -->
<p class="error">Comments could not be loaded. Please refresh to try again.</p>
{/await}
</section>
<aside class="related-sidebar">
<h2>Related Articles</h2>
{#await data.related}
<ul class="related-list skeleton">
{#each [1, 2, 3] as n (n)}
<li class="skeleton-row"></li>
{/each}
</ul>
{:then related}
{#if related.length === 0}
<p>No related articles found.</p>
{:else}
<ul class="related-list">
{#each related as article (article.slug)}
<li>
<a href="/blog/{article.slug}">{article.title}</a>
<span class="reading-time">{article.readingTimeMinutes} min read</span>
</li>
{/each}
</ul>
{/if}
{:catch}
<p class="error">Related articles could not be loaded.</p>
{/await}
</aside>
</article> The {:catch} block in each {#await} is not optional when you care about reliability. If a streamed Promise rejects and there is no {:catch} block, Svelte silently does nothing in the pending state area. The skeleton UI stays visible indefinitely, which is a confusing experience that looks like a stuck loading state. Always include {:catch} for streamed Promises, even if the message is brief.
The pending state UI deserves genuine design effort. A skeleton that matches the shape of the content it is waiting for is far better than a spinner or a blank area. The user’s eye settles on the skeleton while reading the article, and the transition from skeleton to content is smooth and low-surprise. A spinner in a sidebar creates a focal point that competes with the content the user came to read.
Handling Rejections Safely
Unhandled Promise rejections in a streaming context have consequences beyond showing a broken UI. If a rejected Promise propagates to SvelteKit’s streaming layer without being caught, it can result in a server-side unhandled rejection error. Depending on your Node.js version and deployment configuration, unhandled rejections can terminate the process or trigger an error event that your monitoring system will alert on.
The rule is simple: every Promise returned from a streaming load function must have a .catch() handler attached before it is returned. The .catch() handler converts the rejection into a resolved value, a safe fallback that the component can render cleanly.
// Avoid: returning a raw fetch Promise with no error handling.
// If the API is down or returns a non-200, this rejects.
// The rejection may propagate as an unhandled rejection on the server.
const comments = fetch(`/api/blog/posts/${params.slug}/comments`).then((response) =>
response.json()
) // Preferred: chain .catch() to guarantee the Promise always resolves.
// The component receives an empty array on failure, not a rejection.
// The error is logged server-side where it can be acted on.
const comments = fetch(`/api/blog/posts/${params.slug}/comments`)
.then((response) => {
if (!response.ok) {
console.error(`Comments API returned ${response.status} for ${params.slug}`)
return []
}
return response.json()
})
.catch((err) => {
console.error('Comments fetch failed:', err)
return []
}) The two-stage catch here (checking response.ok before .json() and a separate .catch() for network-level failures) handles both categories of failure. The response.ok check catches HTTP errors like 500 or 503. The .catch() catches network errors like DNS failures, connection resets, and timeouts. Both are real in production.
Some teams prefer wrapping each deferred fetch in a helper that standardizes the fallback behavior:
// src/lib/server/streaming.ts
// A helper for fetches that should stream with a safe fallback on failure.
async function streamFetch<T>(
fetchPromise: Promise<Response>,
fallback: T,
label: string
): Promise<T> {
return fetchPromise
.then((response) => {
if (!response.ok) {
console.error(`[stream] ${label} returned ${response.status}`)
return fallback
}
return response.json() as Promise<T>
})
.catch((err) => {
console.error(`[stream] ${label} failed:`, err)
return fallback
})
}
export { streamFetch } // src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
import { streamFetch } from '$lib/server/streaming'
import type { Comment, RelatedArticle } from '$lib/types/blog'
export const load: PageServerLoad = async ({ params, fetch }) => {
const postResponse = await fetch(`/api/blog/posts/${params.slug}`)
if (!postResponse.ok) {
error(postResponse.status === 404 ? 404 : 500, 'Post not found')
}
const post = await postResponse.json()
// streamFetch wraps the fetch Promise with consistent error handling.
// The type parameter and fallback make the contract explicit.
const comments = streamFetch<Comment[]>(
fetch(`/api/blog/posts/${params.slug}/comments`),
[],
`comments:${params.slug}`
)
const related = streamFetch<RelatedArticle[]>(
fetch(`/api/blog/posts/${params.slug}/related?limit=3`),
[],
`related:${params.slug}`
)
return { post, comments, related }
} The streamFetch helper makes the intent explicit at the call site: this fetch is deferred, its failure mode is a known fallback, and the failure will be logged with a label that identifies it in your logging system. The component does not need a {:catch} block that is reachable in practice, because streamFetch guarantees resolution, but including {:catch} as a defensive measure is still good practice.
Practical Example: A User Dashboard with Layered Streaming
A user dashboard is a canonical use case for streaming. Dashboards typically combine a small amount of critical identity information (who the user is, their account status) with several panels of secondary data (activity feed, notifications, recommendations, usage statistics). The critical data is fast. The secondary data varies widely in latency depending on its source.
Type definitions
// src/lib/types/dashboard.ts
export interface Account {
id: string
name: string
email: string
plan: 'free' | 'pro' | 'enterprise'
balance: number
}
export interface ActivityItem {
id: string
description: string
occurredAt: string
type: 'login' | 'purchase' | 'export' | 'update'
}
export interface Notification {
id: string
title: string
body: string
read: boolean
createdAt: string
}
export interface Recommendation {
id: string
title: string
url: string
reason: string
}
export interface UsageStat {
label: string
value: number
unit: string
percentageOfLimit: number
} The dashboard server load
// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'
import { redirect, error } from '@sveltejs/kit'
import type {
Account,
ActivityItem,
Notification,
Recommendation,
UsageStat
} from '$lib/types/dashboard'
export const load: PageServerLoad = async ({ locals, fetch }) => {
// Authentication guard. Not inside a try/catch.
// See Article 8 on why redirect() must not be wrapped in try/catch.
if (!locals.user) {
redirect(303, '/login?returnTo=/dashboard')
}
// Phase 1: Critical data.
// The dashboard cannot render without the account details.
// Await this before returning anything.
const accountResponse = await fetch(`/api/users/${locals.user.id}/account`)
if (!accountResponse.ok) {
error(500, 'Failed to load account information.')
}
const account: Account = await accountResponse.json()
// Phase 2: Deferred data. Start all deferred fetches immediately
// so they run concurrently while the initial HTML is being sent.
// Each one handles its own failure and resolves to a safe fallback.
const recentActivity: Promise<ActivityItem[]> = fetch(
`/api/users/${locals.user.id}/activity?limit=10`
)
.then((response) => (response.ok ? response.json() : []))
.catch(() => [])
const notifications: Promise<Notification[]> = fetch(
`/api/users/${locals.user.id}/notifications?unread=true`
)
.then((response) => (response.ok ? response.json() : []))
.catch(() => [])
const recommendations: Promise<Recommendation[]> = fetch(
`/api/users/${locals.user.id}/recommendations`
)
.then((response) => (response.ok ? response.json() : []))
.catch(() => [])
const usageStats: Promise<UsageStat[]> = fetch(`/api/users/${locals.user.id}/usage`)
.then((response) => (response.ok ? response.json() : []))
.catch(() => [])
// Return immediately. 'account' is resolved and in the initial HTML.
// The four Promise values stream individually as they settle.
return {
account,
recentActivity,
notifications,
recommendations,
usageStats
}
} The dashboard component
<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
import type { PageProps } from './$types'
let { data }: PageProps = $props()
// Derived from the resolved account. This is synchronous because
// 'account' is a resolved value, not a Promise.
const isProOrEnterprise = $derived(
data.account.plan === 'pro' || data.account.plan === 'enterprise'
)
</script>
<div class="dashboard">
<!-- Critical section: rendered in initial HTML. -->
<header class="dashboard-header">
<h1>Welcome back, {data.account.name}</h1>
<p class="plan-badge">{data.account.plan} plan</p>
</header>
<div class="dashboard-grid">
<!-- Notifications panel -->
<section class="panel notifications-panel">
<h2>Notifications</h2>
{#await data.notifications}
<div class="panel-loading" aria-label="Loading notifications">
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
</div>
{:then notifications}
{#if notifications.length === 0}
<p class="empty-state">You are all caught up.</p>
{:else}
<ul class="notification-list">
{#each notifications as notification (notification.id)}
<li class="notification" class:unread={!notification.read}>
<strong>{notification.title}</strong>
<p>{notification.body}</p>
</li>
{/each}
</ul>
{/if}
{:catch}
<p class="panel-error">Notifications unavailable.</p>
{/await}
</section>
<!-- Recent activity panel -->
<section class="panel activity-panel">
<h2>Recent Activity</h2>
{#await data.recentActivity}
<ul class="activity-list skeleton">
{#each [1, 2, 3, 4, 5] as n (n)}
<li class="activity-item skeleton-row"></li>
{/each}
</ul>
{:then items}
{#if items.length === 0}
<p class="empty-state">No recent activity to show.</p>
{:else}
<ul class="activity-list">
{#each items as item (item.id)}
<li class="activity-item activity-type-{item.type}">
<span class="activity-description">{item.description}</span>
<time datetime={item.occurredAt}>{item.occurredAt}</time>
</li>
{/each}
</ul>
{/if}
{:catch}
<p class="panel-error">Activity feed unavailable.</p>
{/await}
</section>
<!-- Usage statistics panel (pro/enterprise only) -->
{#if isProOrEnterprise}
<section class="panel usage-panel">
<h2>Usage</h2>
{#await data.usageStats}
<div class="usage-skeleton">
{#each [1, 2, 3] as n (n)}
<div class="stat-skeleton"></div>
{/each}
</div>
{:then stats}
<ul class="usage-list">
{#each stats as stat (stat.label)}
<li class="usage-stat">
<span class="stat-label">{stat.label}</span>
<span class="stat-value">{stat.value} {stat.unit}</span>
<div class="stat-bar">
<div
class="stat-fill"
style:width="{Math.min(stat.percentageOfLimit, 100)}%"
></div>
</div>
</li>
{/each}
</ul>
{:catch}
<p class="panel-error">Usage data unavailable.</p>
{/await}
</section>
{/if}
<!-- Recommendations panel -->
<section class="panel recommendations-panel">
<h2>Recommended for You</h2>
{#await data.recommendations}
<div class="recommendations-skeleton">
{#each [1, 2] as n (n)}
<div class="rec-skeleton"></div>
{/each}
</div>
{:then recs}
{#if recs.length === 0}
<p class="empty-state">Check back soon for personalized recommendations.</p>
{:else}
<ul class="recommendation-list">
{#each recs as rec (rec.id)}
<li class="recommendation">
<a href={rec.url}>{rec.title}</a>
<p class="rec-reason">{rec.reason}</p>
</li>
{/each}
</ul>
{/if}
{:catch}
<p class="panel-error">Recommendations unavailable.</p>
{/await}
</section>
</div>
</div> The $derived call on isProOrEnterprise works correctly here because data.account is a resolved value, not a Promise. Reactive derivations from resolved data work exactly as they do on any other page. Reactive derivations from Promise data require nesting the $derived inside a resolved {:then} block or storing the resolved value in a $state variable, which complicates things.
Keep resolved data and streamed data as separate concerns when possible: derive from resolved data in the script block, and let {#await} handle all Promise-dependent rendering.
Platform Support: Where Streaming Actually Works
Streaming via chunked transfer encoding requires the HTTP connection between the browser and the server to remain open while the server sends data in pieces. This is standard behavior on direct Node.js or Deno deployments. It is not universal across every deployment platform and proxy configuration.
Node.js adapter (@sveltejs/adapter-node): Full support. Chunked transfer encoding is native to Node.js HTTP. Streaming works as described.
Cloudflare Workers (@sveltejs/adapter-cloudflare): Full support. The Cloudflare Workers runtime supports the Web Streams API, which SvelteKit uses on edge platforms. Streaming works.
Vercel (@sveltejs/adapter-vercel): Supported with the streaming: true option in the adapter configuration. Without this flag, the adapter buffers the response. Set it explicitly:
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel'
const config = {
kit: {
adapter: adapter({
// Enable streaming. Without this, SvelteKit deferred Promises
// are buffered by the adapter and sent as a single response.
streaming: true
})
}
}
export default config AWS Lambda (via @sveltejs/adapter-node behind API Gateway or Lambda Function URLs): Problematic. API Gateway has historically buffered responses, preventing chunked transfer encoding from working end-to-end. Lambda Function URLs have better support for response streaming when using the RESPONSE_STREAM invocation mode, but this requires explicit configuration and is not the default. When deploying to Lambda behind API Gateway, test streaming behavior explicitly and assume it may be buffered.
Firebase Hosting with Cloud Functions: Firebase Hosting acts as a CDN that buffers function responses before delivering them. Streaming data from Cloud Functions through Firebase Hosting does not work. The CDN collects the entire response before forwarding it. If you use Firebase Functions directly (bypassing Hosting for API routes), streaming is technically possible but requires careful configuration.
NGINX as a reverse proxy: NGINX buffers upstream responses by default. The proxy_buffering off directive must be set for the specific location block to allow streaming to pass through:
location / {
proxy_pass http://sveltekit_upstream;
proxy_buffering off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding on;
} Without proxy_buffering off, NGINX collects the entire response from the SvelteKit server before forwarding it to the browser. The user experience is identical to not streaming at all. The deferred Promises are still resolved and sent, but they arrive at the browser all at once after the slowest one settles. The streaming benefit is lost.
The practical test: The most reliable way to verify that streaming is actually working end-to-end in your deployment is to add a deferred Promise with a known delay and observe whether the initial HTML arrives before the delay completes:
// Temporary diagnostic: add a deliberately slow deferred value.
// If streaming works, the page shell appears before the 2-second delay.
// If streaming is buffered, nothing appears until after 2 seconds.
const diagnosticStream = new Promise<string>((resolve) => {
setTimeout(() => resolve('stream confirmed'), 2000)
})
return {
post,
comments,
related,
// Remove this before deploying to production.
diagnosticStream
} Streaming and the {#await} Block During Client Navigation
An important behavioral distinction: during client-side navigation (when the user clicks a link and SvelteKit navigates without a full page load), streaming behaves differently from a full SSR request.
During client navigation, the server load function runs on the server, but the response is sent to the browser as a JSON object rather than as HTML chunks. SvelteKit resolves all Promises on the server before sending the JSON response to the client. The client receives all the data at once and uses it to update the page.
This means the {#await} block’s pending state is not shown during client navigation when a server load function is involved. The browser sees no latency for the deferred data during transitions because the server resolves everything before responding. The streaming benefit is a server-rendered-only optimization.
This is not a flaw. It is appropriate behavior. Full page loads are the slow path where streaming provides the most value. Client navigations are already fast because the browser has the page shell and the JavaScript runtime loaded. The extra round trip to resolve Promises server-side before sending the JSON is an acceptable cost for the simplicity of the client navigation model.
The consequence for component design: the pending state of each {#await} block is primarily a server-rendered concern. Design skeletons and loading states with the first-load experience in mind, not the transition experience.
Common Mistakes and Anti-Patterns
Awaiting deferred Promises before returning
// Avoid: awaiting the deferred data before returning eliminates the streaming benefit.
// The load function waits for all four fetches before sending any HTML.
export const load: PageServerLoad = async ({ params, fetch }) => {
const postResponse = await fetch(`/api/posts/${params.slug}`)
const post = await postResponse.json()
// These are awaited. No streaming. Total time = max(comments, related).
const comments = await fetch(`/api/posts/${params.slug}/comments`).then((response) =>
response.json()
)
const related = await fetch(`/api/posts/${params.slug}/related`).then((response) =>
response.json()
)
return { post, comments, related }
} // Preferred: return Promises directly. Do not await deferred data.
export const load: PageServerLoad = async ({ params, fetch }) => {
const postResponse = await fetch(`/api/posts/${params.slug}`)
const post = await postResponse.json()
const comments = fetch(`/api/posts/${params.slug}/comments`)
.then((response) => (response.ok ? response.json() : []))
.catch(() => [])
const related = fetch(`/api/posts/${params.slug}/related`)
.then((response) => (response.ok ? response.json() : []))
.catch(() => [])
return { post, comments, related }
} Streaming critical data that error() needs to guard
// Avoid: streaming the post itself.
// If the post does not exist, there is no way to call error() after returning.
// The component will receive a rejected Promise or an empty value
// and have no way to show a 404 page.
export const load: PageServerLoad = async ({ params, fetch }) => {
// Do not stream the resource that determines whether the page is valid.
const post = fetch(`/api/posts/${params.slug}`).then((response) => response.json())
return { post }
} // Preferred: await critical data so error() can be called if needed.
// Only defer data that is genuinely supplementary.
export const load: PageServerLoad = async ({ params, fetch }) => {
const postResponse = await fetch(`/api/posts/${params.slug}`)
if (!postResponse.ok) {
error(postResponse.status === 404 ? 404 : 500, 'Post not found')
}
const post = await postResponse.json()
const comments = fetch(`/api/posts/${params.slug}/comments`)
.then((response) => (response.ok ? response.json() : []))
.catch(() => [])
return { post, comments }
} If the resource that defines the page (the post, the product, the user profile) is streamed and its fetch fails, the component is in an unrecoverable state: it cannot show the page because there is no content, and it cannot show a proper error page because error() was never called. Always await the resource the page identity depends on.
Missing {#await} entirely or leaving out {:catch}
<!-- Avoid: treating streamed data as if it were resolved. -->
<!-- data.comments is a Promise. Accessing .length on a Promise is undefined. -->
<script lang="ts">
let { data } = $props()
</script>
<ul>
{#each data.comments as comment (comment.id)}
<li>{comment.body}</li>
{/each}
</ul> <!-- Preferred: use {#await} to handle all three states. -->
<!-- Including {:catch} protects against stuck loading states. -->
{#await data.comments}
<p>Loading comments...</p>
{:then comments}
<ul>
{#each comments as comment (comment.id)}
<li>{comment.body}</li>
{/each}
</ul>
{:catch}
<p>Comments could not be loaded.</p>
{/await} TypeScript will catch the first mistake in most cases because data.comments is typed as Promise<Comment[]>, not Comment[]. The {#each} block expects an array and TypeScript will complain. But TypeScript will not catch it if the type annotation is loose or missing, so the {#await} discipline matters regardless of TypeScript coverage.
When NOT to Stream
Streaming is a targeted optimization. It is not appropriate in all situations.
Universal load functions cannot stream. Streaming requires the ability to write chunks to an HTTP connection, which only exists in a server context. A +page.ts load function runs on both the server and the client. When it runs on the client during navigation, there is no HTTP connection to write to. SvelteKit ignores unresolved Promises returned from universal load functions and does not stream them. If you return a Promise from a +page.ts, it resolves before the data reaches the component, but without the streaming timing guarantee. Use +page.server.ts for any load function that intends to stream.
Pages where skeleton UI is worse than a small delay should not stream. Streaming is a perceived performance optimization. It trades a uniform latency for a progressive reveal. If your deferred data resolves in under 100ms, the skeleton flash (the brief moment where the skeleton is visible before the real content pops in) is jarring and more distracting than just waiting. For fast supplementary data, parallel fetching without streaming is cleaner.
Pages where layout shift from deferred data is disruptive should reconsider streaming. If the comments section expanding from a skeleton to real comments causes the page to reflow significantly and pushes other content down, streaming hurts the experience. Reserve explicit space with CSS for deferred sections (min-height or a fixed placeholder height) or reconsider whether the data should be deferred at all.
SSR-disabled pages (export const ssr = false in the page) never execute the load function on the server. The load function runs in the browser, where there is no HTTP connection to stream over. Streaming has no effect on SSR-disabled pages.
Endpoints and API routes are not SvelteKit pages. They do not have load functions. Streaming in the context of this article refers specifically to server load functions for rendered pages.
Performance and Scaling Considerations
The server-side cost of streaming is the cost of keeping an HTTP connection open for longer than a standard request/response cycle. On platforms billed by connection-minutes or request duration (serverless platforms like Vercel Functions, AWS Lambda), a streaming request that keeps a connection open for 600ms while waiting for the slowest deferred Promise is billed for that duration. Non-streaming requests that wait 600ms server-side before responding are billed for the same duration. The billing cost is equivalent. The user experience benefit of streaming is the reduction in time to first content.
On traditional server deployments with persistent processes, connection overhead is low and streaming is essentially free from a resource perspective. The server-side Promises run regardless of whether the results are streamed or awaited. The streaming mechanism adds minimal overhead.
Cache headers interact with streaming in a nuanced way. A Cache-Control: public, max-age=300 header on a streamed response means the CDN caches the full response, including the streamed chunks, as a complete body. Subsequent requests served from CDN cache receive all the data at once, with no streaming. CDN caching and streaming are compatible in the sense that the first request streams and CDN-cached subsequent requests do not. Design your cache headers with this in mind: private, user-specific data should use Cache-Control: private, no-store and will stream on every request. Public data cached by a CDN will stream only on cache misses.
What’s Next
Streaming is one strategy for managing slow data: defer it and let the page render without blocking on it. Another strategy is to make sure fast data is actually fast, by running everything concurrently and avoiding the hidden sequencing that await parent() can introduce. The next article, Parallel Loading and Avoiding Waterfalls, covers how SvelteKit’s concurrent execution model works, how to spot and fix request waterfalls, and the Promise.all patterns that keep a dashboard snappy even when it loads from multiple sources.
See Also
- SvelteKit Streaming Documentation — official reference including the list of supported platforms and adapter-specific notes
- SvelteKit
{#await}Template Syntax — full reference for the three-block{#await}{:then}{:catch}syntax used in every streaming component