Skip to content
svelte-vitals
Esc
↑↓navigate↵open⌘Jpreview
On this page

performance/load-waterfall · Load waterfall

Dependent sequential awaits in a universal load cost a network round trip from the browser per hop.

Severity: warning · Category: performance

What it checks

Flags await chains in a universal load (+page.ts / +layout.ts) where a later await uses the result of an earlier one, whether directly, through destructured bindings, or through intermediate constants. Each dependent hop is a full network round trip from the browser on client-side navigation.

The scan is deliberately conservative:

  • It follows the load body’s straight-line statements (directly try-wrapped ones included) and does not enter if branches, loops, or nested functions.
  • await parent() is never flagged itself, but data derived from it counts as a dependency.
  • Reading a response body (await res.json() and friends, or a Promise.all of such reads) is not a hop, since it costs no extra round trip, though data parsed from it still carries the dependency forward.
  • A method of an object the load created itself, awaited for its effect alone (const auth = getAuth(); await auth.initialize()), fills that object: a later await that reads it depends on it.
  • A container a callback over an earlier result fills (lists.forEach((l) => ids.add(l.id))) carries that result: a later await that reads it depends on it.
  • Awaiting a promise that an earlier result hands over, such as one an ancestor load already started and passed down through parent() (await deferred.state), is not a hop either: no request starts there. A promise this load starts from an earlier result (const p = fetch(user.url), then await p) still is.
  • Dependent chains in server loads are exempt: they cannot be parallelized and already run server-side.
  • Loads on a route without client-side rendering are exempt: without a client runtime the universal load only runs during SSR. The route takes the nearest csr export along its own +page files and then up its layout chain, as SvelteKit does; csr = false and csr = dev (with dev from $app/environment, false in every production build) turn it off, and any other csr export turns it back on. Where a node’s universal and server modules both export csr, the universal one wins. A layout’s load is exempt only when every page under it has csr off.

Why it matters

SvelteKit’s performance guidance names request waterfalls as a primary latency source. A universal load re-runs in the browser on client-side navigation, so a chain of N dependent requests costs N sequential round trips on every visit.

Moving the chain to a server load keeps the logic but runs the hops server-to-server, collapsing the client cost to one round trip.

How to fix

Move the dependent chain into a server load:

export async function load({ fetch }) {
  const user = await fetch(`/api/user`).then((r) => r.json());
  const posts = await fetch(`/api/posts/${user.id}`).then((r) => r.json());
  return { user, posts };
}

If part of the data is independent, split it out and parallelize (see performance/sequential-awaits).

Limitations

Only the literal dependent-chain shape is detected; chains hidden behind branches, loops, helper functions, or module-level caches are not. A finding can be silenced per line with // svelte-vitals-disable-next-line performance/load-waterfall.

Mode differences

None. This rule reads source, the same .svelte and .ts files, everywhere it runs. The CLI, the Vite plugin’s build pass, and the live dashboard’s static baseline all report it identically, and the rendered-HTML pass never re-evaluates it. Scoping a run with --route skips it: component-scoped rules have no route to attribute a finding to.

Disabling

export default {
  rules: {
    'performance/load-waterfall': 'off'
  }
};