SvelteKit preloadStrategy 'preload-js' delays mobile FCP: script preloads are emitted before stylesheets
SvelteKit 2.70 (adapter-node, SSR, Svelte 4) app with no script hints in its SSR HTML: with the default kit.output.preloadStrategy 'modulepreload', render.js only writes <link rel=modulepreload> while prerendering (Head.add_link_tag returns early otherwise), and the Link header was being stripped to avoid Cloudflare 103 Early Hints issues. Switching to preloadStrategy: 'preload-js' did put ~35 <link rel="preload" as="script" crossorigin="anonymous"> tags into every page, as intended. But Lighthouse 12 mobile (simulated throttling, Chrome 151) got worse on most pages, not better: FCP rose by 0.5-1.8 s and the performance score dropped (e.g. 0.93 -> 0.75 on a content page, 0.98 -> 0.88 on a profile page) even though TBT was unchanged. Nothing in the network panel looked broken; every preloaded chunk is genuinely needed for hydration. Why do the preloads hurt paint metrics, and should they be kept?
Root cause: tag order in kit's head, plus Lantern's bandwidth model. In @sveltejs/kit/src/runtime/server/page/render.js, Head.build() serializes in this order:
return [
...this.#http_equiv,
...this.#link_tags,
...this.#script_preloads, // preload-js tags land here
this.#rendered, // your <svelte:head> (e.g. a font preload)
...this.#style_tags,
...this.#stylesheet_links // render-blocking CSS comes LAST
].join('\n\t\t');So with preload-js the browser discovers ~35 High-priority script requests (and any <svelte:head> font preload) before the VeryHigh-priority stylesheets. Lighthouse's Lantern simulator shares the throttled ~1.6 Mbps link across all in-flight requests, so the CSS (the only thing FCP waits on for an SSR page) finishes later. The scripts only make hydration earlier, which the v12 score doesn't reward.
Measured A/B (local prod build, mobile, 3-run medians, score / FCP / LCP ms):
| Page | preload-js | default (no script preloads) |
|---|---|---|
| profile | 0.88 / 2789 / 3311 | 0.98 / 1514 / 2270 |
| landing | 0.82 / 2942 / 3947 | 0.85 / 2418 / 3956 |
| content (/p) | 0.75 / 3957 / 4429 | 0.93 / 2194 / 2870 |
A single <link rel=preload as=font> for the body face (39 KB woff2) did not hurt once the script preloads were gone.
Fix: leave kit.output.preloadStrategy at its default for SSR apps where paint matters more than hydration time; the inline entry import() at the end of a small HTML document discovers the scripts almost as early anyway. If you need hints, filter them with resolve(event, { preload: ({ type, path }) => ... }) to a few entry chunks rather than preloading all of them. Caveat: Lantern doesn't model HTTP/2 priorities, so the real-world penalty behind an H2 CDN is probably smaller; measure on production before deciding either way.