Skip to content

perf: lower manifest script preload priority - #8212

Draft
maxktz wants to merge 1 commit into
TanStack:mainfrom
maxktz:fix/preload-priority
Draft

perf: lower manifest script preload priority#8212
maxktz wants to merge 1 commit into
TanStack:mainfrom
maxktz:fix/preload-priority

Conversation

@maxktz

@maxktz maxktz commented Sep 2, 2026

Copy link
Copy Markdown

PR description mostly AI generated

Changes

This draft proposes rendering TanStack Start's generated JavaScript preloads with fetchPriority="low" for both module and IIFE client output.

The change is intentionally small:

  • getScriptPreloadAttrs adds low fetch priority to generated manifest preloads
  • React SSR coverage verifies the rendered module preload markup
  • React DOM coverage verifies IIFE script preloads
  • Vue's simulated SSR preload markup includes the same attribute so hydration reconciliation remains accurate
  • a router-core patch changeset is included

The purpose of this draft is to share a reproducible performance investigation and get maintainer feedback on whether low priority is the correct framework default. The evidence is promising, but it comes from one application and there are open questions described below.

Context

I migrated the same SSR portfolio from Next.js 16.3.0 to TanStack Start 1.168.49 with React 19.2.8. The design, content, fonts, data, hosting provider, cache policy, and user-facing behavior were kept equivalent.

The migration reduced HTML size and request count, and LCP remained comparable or improved, but mobile FCP initially regressed from roughly 1.15 seconds to 2.33 seconds. The page's SSR content was already present, so the investigation focused on resources competing before the first paint rather than server rendering or client execution.

Public reproductions:

What the investigation found

The unchanged TanStack document emitted four startup resources as ordinary module preloads:

<link rel="modulepreload" href="/assets/index-....js" />
<link rel="modulepreload" href="/assets/jsx-runtime-....js" />
<link rel="modulepreload" href="/assets/button-....js" />
<link rel="modulepreload" href="/assets/routes-....js" />

Chrome assigned all four requests High priority. They started alongside render-blocking CSS and fonts even though the SSR page did not need JavaScript to produce its first paint.

The patched document emitted the same resources as:

<link rel="modulepreload" fetchPriority="low" href="/assets/index-....js" />

The network records then reported those four requests as Low priority. The resource set, script tags, CORS behavior, and module graph were otherwise equivalent. Content hashes differed because these were separate builds.

Deployment Startup JavaScript priority
Next.js Low for all observed initial scripts
TanStack Start unchanged High for all four generated manifest preloads
TanStack Start patched Low for the same four generated manifest preloads

A later dynamically imported Motion chunk remained High in the patched application. This patch only changes generated manifest preloads, not arbitrary scripts or runtime imports.

Comparison with Next.js and React

This is not an application-specific pattern invented for the reproduction. The exact framework versions used by the site already apply the same priority policy through different plumbing:

  1. Next.js getRequiredScripts selects the bootstrap script, and the app renderer passes it to React as bootstrapScripts.
  2. React 19.2.8's Fizz DOM renderer hardcodes fetchPriority: 'low' for both classic bootstrap script preloads and bootstrapModules module preloads.
  3. Next.js lazy chunk preloading also calls ReactDOM.preload for JavaScript with fetchPriority: 'low'.

The implementations are not identical. Next's App Router uses React bootstrap and preinit APIs, while TanStack Start builds explicit modulepreload links from the Vite or Rsbuild manifest. The shared policy is that early JavaScript fetching should not compete at high priority with render-blocking resources.

The HTML standard explicitly passes a modulepreload element's fetchpriority value into its module graph fetch options. Chrome's Fetch Priority guidance also recommends lowering non-critical preloaded scripts when they would otherwise compete with critical resources. Browsers without support ignore the hint without changing module semantics.

Benchmark results

Controlled remote Lighthouse runs

Three runs per variant were interleaved against the three public Vercel deployments. These medians use Lighthouse simulated throttling.

Mobile median Next.js TanStack unchanged TanStack patched
FCP 1.143 s 2.230 s 1.650 s
LCP 2.779 s 2.471 s 2.499 s
Desktop median Next.js TanStack unchanged TanStack patched
FCP 371 ms 497 ms 353 ms
LCP 518 ms 497 ms 376 ms

Under this methodology, the patch improved median FCP by 580 ms on mobile and 144 ms on desktop compared with unchanged TanStack Start.

An earlier local three-way Lighthouse simulation showed the same direction: Next.js 1.063 seconds, unchanged TanStack Start 2.416 seconds, and patched TanStack Start 1.066 seconds for FCP.

Applied throttling caveat

The same deployments were also tested using DevTools applied network and CPU throttling instead of Lighthouse simulation.

Mobile applied-throttling median TanStack unchanged TanStack patched
FCP 1.757 s 1.744 s

That difference is only 13 ms. This is important: the large improvement is repeatable under Lighthouse simulation, but it did not reproduce at the same magnitude under applied throttling. The draft should not be interpreted as proof of an equivalent field-metric improvement.

Initial production comparison that prompted the investigation
Measurement Next.js TanStack Start unchanged Difference
Mobile Lighthouse score 94 92 -2
Mobile FCP 1.15 s 2.33 s 102% slower
Mobile LCP 2.79 s 2.55 s 8.5% faster
Mobile Speed Index 3.98 s 4.13 s 3.7% slower
Mobile TBT 31 ms 0 ms 31 ms better
Mobile CLS 0 0 same
Desktop Lighthouse score 100 100 same
Desktop FCP 360 ms 515 ms 43% slower
Desktop LCP 580 ms 555 ms 4.4% faster
Warm TTFB 231 ms 226 ms 2.1% faster
Complete HTTP response 329 ms 302 ms 8.4% faster
HTML transferred 319 KB 197 KB 38.5% smaller
Total transferred 364 KB 324 KB 11% smaller
Requests 16 10 37.5% fewer

This was an initial comparison, not the controlled three-run experiment above.

PageSpeed Insights results and variability

One complete comparable PageSpeed set produced these results:

Mobile measurement Next.js TanStack unchanged TanStack patched
Performance score 91 93 99
FCP 2.126 s 2.272 s 1.051 s
LCP 2.851 s 2.422 s 2.251 s
Speed Index 4.308 s 4.228 s 1.051 s
TBT 0 ms 0 ms 23 ms
CLS 0 0 0
Desktop measurement Next.js TanStack unchanged TanStack patched
Performance score 100 100 100
FCP 356 ms 482 ms 282 ms
LCP 449 ms 482 ms 442 ms
Speed Index 443 ms 482 ms 282 ms
TBT 21 ms 12 ms 2 ms
CLS 0 0 0

These individual PageSpeed results were noisy. The same patched deployment produced mobile FCP values of 2.119 seconds and 1.051 seconds in two successful runs. Several other runs failed in PageSpeed's backend with throttling or overloaded-render-server errors. The controlled medians above are more useful than selecting the best PageSpeed result.

Reports:

Risks and open questions

  1. This changes fetch scheduling, so it can create a performance tradeoff even though it does not change script behavior. Hydration or time to interaction could become slower when JavaScript is more important than the competing resources.
  2. An ssr: false route can require JavaScript before useful content appears. That case should be benchmarked under constrained networking before making this a universal default.
  3. The current patch applies through the shared router-core helper and therefore affects React, Solid, and Vue manifest rendering. A Start-specific default or an override may be preferable.
  4. Static Early Hints call getScriptPreloadAttrs, but currently copy only href, rel, as, and crossOrigin into the HTTP hint. They do not carry the new fetchPriority. Where a browser acts on that hint before parsing the HTML, the HTML attribute may arrive too late to provide the intended scheduling behavior.
  5. The benchmarks cover one relatively small SSR application. They do not establish the best default for large applications, SPAs, RSC applications, selective SSR, or applications whose LCP depends on client JavaScript.
  6. There is no field-data comparison. The deployed reproductions intentionally were not exposed as an A/B test to production users.

The main question for maintainers is whether matching React's low-priority bootstrap behavior is the right default for all generated TanStack manifest preloads, or whether the framework should distinguish entry, route, selective-SSR, and user-configured assets.

Validation

The following affected checks pass locally:

  • ESLint for 32 affected projects
  • type tests for 36 affected projects and their dependent tasks
  • unit tests for 29 affected projects and their dependent tasks
  • focused React Scripts.test.tsx coverage
  • focused Vue Scripts.test.tsx coverage
  • git diff --check

The upstream example-app browser check and a constrained ssr: false performance comparison are still outstanding while this remains a draft.

Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with the relevant test commands, or tests do not apply to this pull request.
  • I fully understand the code in this pull request, including any code generated with AI assistance.

Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

鉂わ笍 Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant