Fixing SvelteKit Hydration Waterfalls
Your SvelteKit route paints its server-rendered HTML at 480 ms and then goes dead for another 1.8 seconds while the Network panel draws a perfect staircase: one _app/immutable/chunks/*.js request starting only after the previous one finished, then two fetch() calls the initiator column attributes to onMount, and nothing on the page responds to input until the last of them lands.
That shape is not a bandwidth problem and it is not a bundle-size problem. It is a discovery chain: a sequence of requests where each URL only becomes known to the browser once the previous response has been downloaded, parsed and executed. The parent topic on SvelteKit resource loading covers how the build decides which URLs reach the head in the first place. This page is about everything that happens below that set — the part of the request tree that first paint hides and that only a post-paint measurement will show you.
Root cause: the head hints stop at the static import closure
When SvelteKit fills %sveltekit.head%, it reads the matched route’s record in the Vite client manifest and emits one hint per entry in file, css and imports. That imports array is the transitive static import closure of the route’s nodes: everything reachable by a top-level import statement from the layout and page modules. Every one of those URLs is in the HTML, so the preload scanner dispatches them all during parse and they arrive in parallel. Depth one is, by construction, a single round trip no matter how many files it contains.
Nothing below that boundary is hinted. A chunk reached only by import() is listed under dynamicImports in the manifest and gets no head hint at all — that is precisely what makes a dynamic import dynamic. To request it, the browser must first fetch, parse and execute the module containing the call, and in a hydrating app that module does not execute until the entry graph has finished loading. So the earliest a first-level dynamic import can start is after hydration, and each further level costs another full round trip plus a parse.
The compounding is what makes this expensive. Three levels on a 40 ms link is not 120 ms; it is three round trips plus three parse-and-execute steps plus, at the bottom, a data request whose URL depended on the code that arrived last. Lighthouse’s critical request chains audit will not show it either, because that audit is built from the document’s initiator tree and stops at the point where JavaScript takes over. The chain is real, sequential and invisible to every automated check that runs before the page becomes interactive.
Read the start times, not the durations. Rows two and three overlap because their URLs were known at the same moment; rows four through seven never overlap by even a frame, because each start depends on the previous finish. Distinguishing those two shapes is the whole diagnosis, and it is the same reading discipline described in decoding the Chrome DevTools network waterfall.
Vite’s preload helper buys exactly one level
Rollup does not leave a bare import() in the output. It rewrites every dynamic import in a Vite build into a call to the preload helper, roughly:
// What Rollup emits for: const mod = await import('$lib/ChartPanel.svelte');
// The second argument is the list of chunks ChartPanel STATICALLY depends on,
// computed at build time. __vitePreload appends a <link rel="modulepreload">
// for each one and only then starts the import, so the target and its static
// deps travel together in a single round trip instead of two.
const mod = await __vitePreload(
() => import('./ChartPanel-B1x9k2.js'),
['./ChartPanel-B1x9k2.js', './chunk-shared-D3xKq1.js']
);
That helper is why a single dynamic import is one round trip rather than a two-hop chain, and it is worth knowing it exists before you go hunting for a fix that already shipped. What it cannot do is look through the imported chunk at what it will dynamically import at runtime, because that decision has not been made yet. So a component that is lazily loaded and then lazily loads its own charting engine is two levels, and the helper flattens each one independently without flattening the pair. The general form of this problem, outside any framework, is covered in fixing dynamic import request waterfalls.
onMount is the last link in the chain, not the first
The second half of the problem is where the data request lives. Svelte runs onMount callbacks after the component tree has been created and its DOM attached; during hydration that means after the whole depth-one graph has downloaded, parsed and executed. A fetch() written inside onMount is therefore scheduled by definition at the far end of the module chain, and if the component containing it was itself dynamically imported, it is scheduled at the far end of the module chain plus however many discovery hops that import cost.
A load function is the opposite. A server load runs before the HTML exists, so its result is already serialized into the response body and hydration reads it out of the payload with zero requests. A universal load also runs on the server during SSR, and on the client it runs as part of resolving the route node — inside the hinted graph, not after it. Choosing between the two is mostly about where the secrets live; choosing between load and onMount is about whether the request is on the chain at all.
Minimal reproduction
Three files reproduce the full four-level chain. The route renders instantly from SSR, then discovers everything else.
<!-- src/routes/dashboard/+page.svelte -->
<!-- The import() sits inside a template expression, so it is not evaluated
until Svelte renders this block — which happens during hydration, i.e.
after the entire hinted module graph has already loaded. Depth 2 begins
here, and the browser only learns the chunk URL at this instant. -->
{#await import('$lib/ChartPanel.svelte') then { default: ChartPanel }}
<ChartPanel />
{/await}
<!-- src/lib/ChartPanel.svelte -->
<script>
import { onMount } from 'svelte';
let series = $state(null);
onMount(async () => {
// Depth 3: a nested dynamic import. Vite's preload helper hinted this
// component's STATIC deps when it loaded it, but it could not see this
// call, so the 214 KB engine chunk is one more full round trip.
const { renderSeries } = await import('$lib/chart-engine.js');
// Depth 4: the data request cannot even be issued until the code above
// has arrived, because the endpoint depends on the engine's schema.
series = renderSeries(await fetch('/api/metrics').then((r) => r.json()));
});
</script>
Load that route with Slow 4G throttling and a 4× CPU slowdown. The paint is fast and the numbers look good until you notice that the chart appears 1.8 seconds later, and that every request in between started within a few milliseconds of the previous one finishing.
Step two of the protocol below is the measurement that makes this unambiguous:
// Console, once the page has settled. Groups every app and API request by how
// long after first paint it STARTED. Start times separated by roughly one round
// trip with idle CPU between them are a discovery chain; overlapping start
// times are just bandwidth, and need a completely different fix.
const fcp = performance.getEntriesByName('first-contentful-paint')[0].startTime;
performance.getEntriesByType('resource')
.filter((e) => /\/_app\/|\/api\//.test(e.name) && e.startTime > fcp)
.sort((a, b) => a.startTime - b.startTime)
.forEach((e) => console.log(
String(Math.round(e.startTime - fcp)).padStart(5), 'ms after FCP ·',
// 'script' here means the request was initiated by an executing module —
// the signature of a dynamic import the head never hinted.
e.initiatorType.padEnd(6),
Math.round(e.duration) + ' ms ·',
e.name.split('/').pop().split('?')[0]
));
Deterministic fix protocol
- [ ] 1. Capture the paint-to-interactive window. Record a Performance trace at 4× CPU slowdown and Slow 4G. Write down the first-contentful-paint timestamp and the finish time of the last request the page needs before it responds to input. That gap is the number this protocol reduces; nothing else on the page is allowed to regress to shrink it.
- [ ] 2. Classify every post-paint request by depth. Run the snippet above. Requests whose start times are separated by roughly one round trip, with idle main thread between them, form the chain. Assign each a depth number relative to the hinted graph. You cannot fix what you have not counted.
- [ ] 3. Move every
onMountdata fetch intoload. A fetch inonMountis the deepest possible position. Return it from+page.server.jsinstead, projecting down to the fields the component renders, and stream anything slow as an unawaited promise so it does not hold TTFB. This alone deletes the last two rows of the diagram above. - [ ] 4. Hoist the dynamic import trigger to module scope. Move the
import()out of the template and into the top level of+page.js. That module is inside the hinted graph, so the request now starts while the entry graph is still executing — in parallel with hydration rather than one round trip behind it. - [ ] 5. Promote above-the-fold chunks to static imports. If the component is visible on load, it should not be dynamic at all. A static import puts it in the route’s
importsarray in the client manifest, which means amodulepreloadhint in the server-rendered head and a depth of one. Verify by rebuilding and confirming the file moved out ofdynamicImports. - [ ] 6. Flatten nested dynamic imports. Any
import()inside a lazily loaded component is depth n+1. Move it up to the module that imports the component, so both chunks are requested at the same level and the preload helper can hint them together. - [ ] 7. Gate what should genuinely stay deferred. Below-the-fold panels belong outside the window entirely: an
IntersectionObserverwith a generousrootMargin, or arequestIdleCallback, so the request never competes with hydration. - [ ] 8. Re-measure against a depth budget. The target is simple and checkable in code review: zero discovery levels below the hinted graph for anything above the fold, and no request the page needs before input that starts after first paint.
Steps 3 and 4 together produce this shape:
// src/routes/dashboard/+page.js
// Module scope, not template scope. +page.js is in the route's hinted graph, so
// this line executes the moment the entry graph runs — the chunk request starts
// in parallel with hydration instead of one round trip after it. Universal load
// return values are not serialized, so returning a module promise is legal.
const chartPanel = import('$lib/ChartPanel.svelte');
export async function load({ data }) {
// data already carries the metrics the server load fetched, so no request is
// issued here. The module promise is returned unawaited: the component awaits
// it once, and the fetch and the import were never in sequence to begin with.
return { ...data, chartPanel };
}
// src/routes/dashboard/+page.server.js
export async function load({ fetch, setHeaders }) {
// Runs before the HTML exists, and event.fetch writes the response into the
// SSR payload — so hydration reads the metrics out of the document instead of
// reissuing the request. This is the entire depth-4 row of the diagram, gone.
const metrics = await fetch('/api/metrics').then((r) => r.json());
setHeaders({ 'cache-control': 'public, max-age=0, s-maxage=30' });
return { metrics };
}
For step 7, the deferred panel keeps its import() but moves the trigger off the critical window:
<!-- src/routes/dashboard/+page.svelte -->
<script>
let { data } = $props();
let host = $state(null);
let Deferred = $state(null);
$effect(() => {
if (!host) return;
// 600 px of rootMargin starts the chunk roughly one scroll-flick before the
// panel is visible: late enough to stay out of the hydration window, early
// enough that the user never watches it load.
const io = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
Deferred = (await import('$lib/ExportPanel.svelte')).default;
}, { rootMargin: '600px' });
io.observe(host);
return () => io.disconnect();
});
</script>
<!-- ChartPanel is awaited from load(), so it is already resolved here. -->
{#await data.chartPanel then { default: ChartPanel }}
<ChartPanel metrics={data.metrics} />
{/await}
<div bind:this={host} style="min-height: 320px">
{#if Deferred}<Deferred />{/if}
</div>
The min-height is not cosmetic. A panel that arrives after paint and has no reserved box shifts everything below it, converting a loading problem into a layout-stability problem. If you defer, reserve the space.
Before and after
Measured on the /dashboard route from the diagrams: Slow 4G, 4× CPU slowdown, 40 ms round trip, warm HTTP/3 connection, median of nine runs.
| Metric | Before | After | Change |
|---|---|---|---|
| Discovery levels below the hinted graph | 3 | 0 | −3 |
| Requests starting after first paint | 4, sequential | 0 | −4 |
| First contentful paint | 480 ms | 470 ms | flat |
| Chart panel painted | 2,280 ms | 690 ms | −70% |
| Largest contentful paint | 1,240 ms | 640 ms | −48% |
| Total blocking time | 410 ms | 180 ms | −56% |
| INP, p75 field | 260 ms | 120 ms | −54% |
| JavaScript transferred before interactive | 386 KB | 232 KB | −40% |
Head modulepreload hints |
5 | 7 | +2 |
The last two rows are the trade and they are worth stating plainly. Promoting two chunks to static imports adds two hints to the head and moves 154 KB of the engine chunk behind a viewport gate; the bytes that remain arrive in the same round trip as everything else instead of three round trips later. Fewer, larger, parallel requests beat many small sequential ones whenever latency dominates — which on mobile it always does. Keep the hint set small enough to stay meaningful, though: a head crowded with hints has no priorities left to express, and the symptoms of that are catalogued in debugging “preloaded but not used” console warnings.
FAQ
Why does an import() inside an {#await} block cost a round trip when the chunk is already in the build?
Being in the build only means the file exists on the server. What saves a round trip is a modulepreload hint reaching the browser during HTML parse, and SvelteKit only emits hints for the route’s static import closure — which excludes anything reached through import() by definition. On top of that, a template expression is not evaluated until the component renders, so even the call does not happen until hydration. Two separate delays stack: the URL is unknown until the head is long past, and the call site is not reached until the module graph above it has finished. Moving the call to module scope in +page.js fixes the second; a static import fixes both.
Does setting export const ssr = false remove the hydration waterfall?
No — it removes the paint that was hiding it. The chain below the hinted graph is unchanged, but with server rendering off the browser receives a shell containing nothing but the entry script tag and the hints Vite generated for it. Every level of the chain now runs before first paint instead of after it, so the same 1.8 seconds becomes a blank white page rather than a rendered page waiting to become interactive. If a route must be client-only, the chain has to be flattened first, not last.
Hydration finishes fast but interaction is still slow — is that the same problem?
Usually not, and the distinction is visible in one glance at the Performance panel. Put the main thread track directly above the network track. A discovery chain shows as staggered request bars with idle CPU in the gaps — the browser is waiting on bytes. A hydration cost shows as solid long tasks with no network activity at all — the browser is waiting on itself. The fixes do not overlap: the chain is fixed by changing where imports and fetches are declared, while hydration cost is fixed by shipping less code to hydrate, which usually means moving static regions out of interactive components or turning csr off on the routes that do not need it.
Related
- SvelteKit Resource Loading Optimization — up to the parent topic: the load-function graph, the preload directives and the Vite chunk graph that decides which hints reach the head
- Tuning SvelteKit Data Preload Directives — the sibling problem on the navigation side: matching hover, tap and viewport to a measured dwell distribution
- modulepreload & ES Module Loading — what the hint does at the module-map level and where engine support still differs
- Framework-Specific Loading Strategies — up to the section root