This article is published in English.
Ending Request Waterfalls in SvelteKit with Parallel load Functions
Learn why sequential awaits make pages slow, and how SvelteKit load functions, Promise.all, careful parent() calls and streamed promises remove waterfalls.
A page can feel sluggish on a fast connection and a fast backend when its requests run one after another instead of side by side. Each round trip waits for the previous one to finish, so the time to first meaningful content becomes the sum of every request rather than the duration of the slowest. This guide explains where those waterfalls come from and shows how to structure SvelteKit load functions so that critical data arrives in parallel, layout and page loads do not block each other, and slow, non-essential data streams in after the first render.
What a request waterfall looks like
Open the Network panel in the browser's developer tools on a typical client-rendered React or Vue dashboard and load a page that needs several pieces of data. The bars often form a staircase: the first request completes, only then does the second begin, and the third waits for the second. Every step adds a full network round trip.
The code behind that staircase usually looks innocent. A component mounts in the browser, and only then discovers what it needs. Inside an effect or mount hook it does something like const user = await getUser(), then const posts = await getPosts(), then const comments = await getComments(). Three lines, three awaits, nothing obviously wrong.
The catch is what await means. It suspends the function until the promise settles, so the posts request cannot even be sent until the user response has arrived in full, and comments waits in turn for posts. Suppose each call takes around 300 ms once DNS, TLS, server work and transfer are included. The page then has nothing useful to display for roughly the total of all three, not for the time of the longest one.
Nothing throws, no test fails, and every component does exactly what it was told. Users simply watch a spinner for about three times as long as necessary. That invisibility is what makes waterfalls so common.
Why waterfalls keep sneaking in
Three ordinary habits produce most of them:
- Each component fetches its own data. In client-rendered apps, a parent often has to finish loading before a child can mount, and the child cannot start its own request until it receives a prop from the parent. The component tree becomes a request chain.
- Awaiting line by line out of habit. Sequential
awaitstatements read cleanly, which is exactly why they hide the fact that they serialize requests that never depended on one another. - No single view of a page's data needs. When the fetching code is spread across three files, it is hard to notice that all three requests could have been started at the same moment.
Writing faster code does not help. What helps is moving the fetching to a different place and time: before any component exists, in one coordinated spot. In SvelteKit, that spot is the load function. For a framework-neutral look at the same concurrency choices, see the blog's comparison of Promise.all, Promise.race and sequential awaits.
How SvelteKit load functions change the default
Any SvelteKit route can export a load function that runs before its page component renders. Components no longer reach out for data after mounting; the route collects everything first and passes it to the page as a single data prop.
There are two variants, and the choice matters:
- Universal load in
+page.jsruns on the server during server-side rendering and in the browser during client-side navigation. It suits calls to public APIs and returning values that are not serializable. - Server load in
+page.server.jsruns only on the server. Its code is never sent to the browser, so it can safely use database clients, private environment variables and auth tokens.
Layouts follow the same pattern with +layout.js and +layout.server.js.
In most real applications, anything that touches a database, an internal service or a secret belongs in +page.server.js. Its minimal form looks like this:
// src/routes/dashboard/+page.server.js
export async function load({ fetch }) {
const res = await fetch('/api/user');
const user = await res.json();
return {
user
};
}
Three points about this snippet are worth internalizing early.
The load function finishes before the page renders
By the time +page.svelte starts rendering, user is already a plain object. There is no mount hook, no loading flash, no moment where the value is undefined.
Use the fetch that SvelteKit passes in
The fetch argument is not the global one. SvelteKit provides an enhanced version that accepts relative URLs such as /api/user, forwards the incoming request's cookies and headers when it runs during server-side rendering, and, when it targets another route in the same app, calls that handler directly instead of making a real HTTP request. Importing a global fetch loses all of that.
The return value becomes the page's data prop
Whatever the function returns is exposed to the page component as data. In a Svelte 5 component you read it with let { data } = $props(); (older Svelte 4 code uses export let data;) and then render data.user.name in the markup.
The mental model is short: fetch first, render second, and receive resolved values instead of promises you have to manage inside the component.
Moving fetching into load does not remove a waterfall by itself, though. Three consecutive awaits inside load rebuild the same staircase on the server. The real fix comes next.
Starting every independent request at once
The remedy is to fire all independent requests immediately and wait for them together:
// src/routes/dashboard/+page.server.js
export async function load({ fetch }) {
// Kick off all three requests immediately - none of them
// are awaited yet, so none of them block each other.
const userPromise = fetch('/api/user').then(r => r.json());
const postsPromise = fetch('/api/posts').then(r => r.json());
const commentsPromise = fetch('/api/comments').then(r => r.json());
// NOW wait for all of them to finish, in parallel.
const [user, posts, comments] = await Promise.all([
userPromise,
postsPromise,
commentsPromise
]);
return { user, posts, comments };
}
Why this is faster
The deciding factor is the gap between sending a request and awaiting its result. Calling fetch(...) sends the request right away; it does not wait for an await. Attaching .then() merely describes what to do with the response when it eventually arrives. So in the code above:
- the first line sends the
/api/userrequest; - the second line sends
/api/postswhile the first is still in flight; - the third line sends
/api/commentswhile the other two are in flight; - only
Promise.allactually pauses, and it resumes as soon as the slowest of the three completes.
Total time drops from the sum of three requests to roughly the duration of the slowest one. The requests, the server and the data are unchanged; only the placement of the await moved. In an illustrative timing where each request takes about 300 ms plus some overhead, that moves the page from usable at around 960 ms to usable at around 360 ms, and the gap widens on slow networks or under a busy API.
Few changes to a data-heavy page offer this much for so little. There is no library to add and no architecture to redesign; a few lines are reordered.
What to watch for with Promise.all
Promise.all rejects as soon as any one of its promises rejects. If a failed comments request should not take down the whole page, use Promise.allSettled or give each promise its own .catch() that returns a fallback value. Also note that r.json() does not check r.ok; a 404 or 500 response with a JSON error body will be parsed and returned as if it were data, so check the status when correctness depends on it.
The hidden waterfall between layouts and pages
Developers who have learned the Promise.all trick often still ship a second kind of waterfall, one that spans files: how the data loading of a layout interacts with that of the page beneath it.
Out of the box, SvelteKit starts the load functions of +layout.server.js and +page.server.js concurrently, just like the parallel example above. The escape hatch is parent(), which gives a page's load access to the data returned by the layouts above it. Sometimes that is exactly what you need, for instance when the page's query requires a user ID that only the layout fetched. Called too early, though, it serializes everything after it:
// src/routes/dashboard/+page.server.js
// BAD: this creates a waterfall between the layout and the page,
// even if `posts` doesn't actually need anything from `parent()`.
export async function load({ parent, fetch }) {
const { user } = await parent(); // blocks here until layout's load finishes
const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
return { posts };
}
If the posts request really does need user.id, this ordering is unavoidable and perfectly fine; it is a genuine dependency. The problem appears when the page also needs data that has nothing to do with the layout. Awaiting parent() on the first line holds back every later statement, including those independent requests, until the layout finishes.
The fix is to reorder: start the independent work first and await parent() only at the point where its value is required.
// src/routes/dashboard/+page.server.js
// GOOD: independent work starts immediately; parent() is only
// awaited once we actually need the merged result.
export async function load({ parent, fetch }) {
const commentsPromise = fetch('/api/comments').then(r => r.json());
const { user } = await parent(); // runs concurrently with the fetch above
const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
const comments = await commentsPromise;
return { user, posts, comments };
}
Here the comments request is sent before the page waits on the layout, so the two overlap. The posts request still has to wait for user.id, which is correct, and the comments promise has usually settled by the time it is awaited at the end.
The rule of thumb: treat await parent() like any other await. Put it exactly where the value is needed, never reflexively at the top of the function, and launch any request that does not depend on parent data before it.
Streaming data that is not critical
Promise.all is not always the right answer. If one request is slow and its data is not needed for the first thing the user sees, waiting for it makes the entire page as slow as its least important part. On a product page, the name, price and images are critical; the reviews section several screens down is not.
For that case SvelteKit supports streaming. Return a promise from a server load without awaiting it, and SvelteKit sends the rendered page right away, then delivers the promise's value to the browser once it resolves.
// src/routes/product/[id]/+page.server.js
export async function load({ fetch, params }) {
// Critical: awaited, blocks the initial render - but it's fast.
const product = await fetch(`/api/product/${params.id}`).then(r => r.json());
// Non-critical: NOT awaited. This is handed to the page as a
// pending Promise, and SvelteKit streams it in once it resolves.
const reviewsPromise = fetch(`/api/product/${params.id}/reviews`).then(r => r.json());
return {
product, // resolved value
reviews: reviewsPromise // still-pending promise
};
}
The product is awaited because the initial render needs it and the request is quick. The reviews request is started but not awaited, so the page receives a pending promise under data.reviews.
On the page, Svelte's {#await} block handles both states. Inside {#await data.reviews} you render a lightweight placeholder such as a "Loading reviews..." line, and in the {:then reviews} branch you loop over the results. An optional {:catch error} branch shows a message if the request fails.
The user sees the product details almost immediately, the reviews area shows a small loading state, and the real reviews replace it the moment they arrive. There is no extra client-side fetch code and no mount hook.
Streaming caveats
Keep these constraints in mind:
- Streaming works from server load functions. The promise is created on the server and streamed from
+page.server.jsor+layout.server.js. A universal+page.jsload can return a promise too, but it is not streamed from the server in the same way. - Your adapter and hosting must support streamed responses. The Node and Vercel adapters do, as do most modern platforms, but confirm it for less common targets. Proxies that buffer responses can also silently cancel the benefit.
- Handle rejections. A streamed promise that rejects without a
{:catch}branch or a.catch()handler can surface as an unhandled rejection on the server. Give non-critical promises a fallback. - Headers and cookies are fixed once streaming starts. Anything that must set a cookie or a status code needs to be awaited before the response begins.
- Streamed content depends on JavaScript in the browser. Without it, users will only ever see the loading state, so do not stream anything that must be present for search engines or no-JS users.
The complete request lifecycle
With parallel critical fetches, deliberate use of parent() and streaming for non-essential data, a request to a data-heavy route goes through these steps:
- The browser requests the route.
- SvelteKit runs the route's layout and page
loadfunctions on the server. - All independent data, such as user, posts and comments, is fetched in parallel.
- The server renders complete HTML with that data already embedded, so the initial view has no loading state.
- The browser receives a single finished response instead of chasing a series of network calls.
- Svelte hydrates the page, attaching interactivity to markup that is already rendered, while any streamed promises fill in their sections as they resolve.
Compare that with the client-side version from the start. Instead of the browser making three sequential round trips after the page has loaded, the server makes them in parallel before anything is sent, and usually with much lower latency to the data sources than the user's browser has.
A pre-ship checklist for data-heavy routes
Run through these questions before shipping a route that needs several pieces of data:
- Is data for the initial render fetched in a component on mount? Move it into a
loadfunction, usually a server one. - Are there several independent awaits in a row inside
load? Start all the requests first, then wait on them together withPromise.allorPromise.allSettled. - Is
await parent()the first line of a pageload? Move it to where the parent data is actually used, and fire unrelated requests before it. - Is any data irrelevant to the first paint? Return it as an unawaited promise and render it with
{#await}, with a catch branch. - Is the
fetchcoming from theloadarguments? Only that version resolves relative URLs and forwards cookies correctly during server rendering.
Wrapping up
Waterfalls are not evidence of careless code. They are what naturally happens when data fetching is scattered through a component tree. SvelteKit's load functions matter less because they run on the server and more because they put every request a page needs in one visible place, where you can decide which ones run together, which ones genuinely depend on others, and which ones can arrive later. The habit to build is small: stop awaiting by reflex and start awaiting on purpose. Applied to every route, it saves users time on every slow connection and every busy backend.