Skip to content

perf(app-router): preload only the rendered route's client references during SSR - #4114

Open
james-elicx wants to merge 3 commits into
mainfrom
perf/app-router-route-client-reference-preloads
Open

james-elicx wants to merge 3 commits into
mainfrom
perf/app-router-route-client-reference-preloads

Conversation

@james-elicx

@james-elicx james-elicx commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

What

App Router SSR no longer loads every client reference in the app before its first render. Each render waits only for the client references of the route it renders, planned at build time from the RSC module graph.

This is the per-route approach suggested in this comment on #3913. It builds on the route graph walk that #3697 added for grouping client chunks.

How

  • Build (RSC): after the RSC build, collectClientReferencePreloadPlan walks each route's files (page, layouts, templates, boundaries, parallel slots) through static and dynamic imports. Dynamic imports count because a next/dynamic component renders on the server. The result is the client references each route can render.
  • Every render: a walk from the RSC entries that stops at route files finds what renders outside any route: vinext's own client components (error boundaries, layout-segment context) and server actions' results. These load for every route, along with global-error, the root not-found, forbidden and unauthorized boundaries (routes without their own fall back to them), and any reference no walk reaches. Route files that are client references themselves, such as every error.tsx, stay with their own routes.
  • No route: a render without a route (a route-miss 404) uses the root layouts, the root boundaries and global-not-found.
  • Build (SSR): the SSR build exports the plan from virtual:vite-rsc/client-references, as indices into one list of reference keys.
  • Runtime: every SSR call site passes the matched route's pattern to handleSsr: page renders, boundary renders, ISR regeneration and the RSC render-status pass. handleSsr awaits that route's set before rendering.

The render still waits for its client references, as it does today, so a cold render produces the same document as a warm one. Once a module loads, the preloader marks its promise settled the way React tracks thenables (status/value). Without that, React's Flight client waits a tick for the module on the first render after the load, and plugin-rsc's stylesheet beat next/dynamic's nonce'd preload link into the head. It doesn't read the Flight stream, and stylesheet and script hint order doesn't depend on load timing.

Fallbacks load every reference, as before. They cover PPR fallback shells (they abort on the next tick), a route pattern the plan doesn't know, development (where plugin-rsc loads references on demand), and apps that register @vitejs/plugin-rsc themselves with vinext({ rsc: false }), which get a null plan. This doesn't depend on client chunk grouping: disableNextLikeChunking still gets per-route SSR preloads.

Known limitations

A client reference that a route renders but doesn't reach in the module graph, and that isn't reached from outside route files either, isn't preloaded. One example is a server component that builds a specifier at runtime and loads it with import(). That reference loads during the render, the way any client reference did before the global preload. Server action modules are reached through plugin-rsc's server-references module, so the components their results render are in the always-preloaded set.

The always-preloaded set includes every client reference reached from modules outside route files: route handlers, metadata files, middleware, instrumentation and server action modules. If one of those imports a barrel that re-exports client components, those components load for every route. That is never more than main loads today.

Tests

  • tests/app-route-client-preloads-production.test.ts (integration) builds tests/fixtures/app-route-client-preloads and starts the production server. Each check runs on a cold server:
    • concurrent first requests render every client component;
    • another page's client module isn't evaluated;
    • the first document is byte-identical to a warm one;
    • a route-miss 404 renders its client component;
    • a next/dynamic stylesheet keeps its nonce and dynamic precedence on the first request;
    • the other page's client code loads on its own first request.
      On main's global preload the "not evaluated" checks fail. With no preload at all, the warm-equality and next/dynamic checks fail.
  • tests/client-reference-groups.test.ts covers the plan:
    • dynamic imports are followed;
    • references the runtime renders are always preloaded, even when a route also reaches them;
    • global-error, server actions and unreachable references join the always-preloaded set;
    • routeless files are handled;
    • intercepting routes are excluded;
    • route files are canonicalized.
  • tests/app-client-reference-preloader.test.ts covers resolving a route's set, the fallbacks, and the settled-promise marking.

… during SSR

The first App Router SSR render after start imported the client code of
every page before rendering anything, so a cold server loaded the whole
app's client graph for one page.

The RSC build now plans, per route, the client references its page,
layouts, templates, boundaries and parallel slots reach, through static
or dynamic imports, using the route graph walk that groups client chunks.
References reached outside route files (vinext's runtime, server actions),
global-error's and any the walk can't place load for every route. A render
without a route uses the root layouts and boundaries and global-not-found.
The SSR build exports the plan from the client-references module, and
handleSsr awaits the matched route's set before it renders, so a cold
render shows the same document as a warm one without reading the Flight
stream. PPR fallback shells, and patterns the plan doesn't know, still load
every reference.
@pkg-pr-new

pkg-pr-new Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@4114

create-vinext-app

npm i https://pkg.pr.new/create-vinext-app@4114

@vinext/types

npm i https://pkg.pr.new/@vinext/types@4114

vinext

npm i https://pkg.pr.new/vinext@4114

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@4114

commit: 5ed0ee7

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 5ed0ee7 against base 359b4fb using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 7 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 151.5 KB 151.5 KB ⚫ -0.0%
Client entry size (gzip) vinext 141.7 KB 141.7 KB ⚫ -0.0%
Dev server cold start vinext 2.88 s 2.86 s ⚫ -0.6%
Dev server cold start (large Pages Router app) vinext 27.30 s 27.19 s ⚫ -0.4%
Production build time vinext 3.62 s 3.64 s ⚫ +0.5%
RSC entry closure size (gzip) vinext 142.1 KB 142.1 KB ⚫ +0.0%
Server bundle size (gzip) vinext 246.5 KB 247.1 KB ⚫ +0.2%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

… the Flight client

React's Flight client waits a tick for a module promise that doesn't carry
React's thenable status, so a route's first render after a preload emitted
plugin-rsc's stylesheet ahead of next/dynamic's nonce'd preload link. Also
add routeClientReferences to the dispatch test's client-references mock.
@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
response-store-demo preview production
workers-cache preview production
kv preview production
static-assets-cache preview production
static-assets-pages preview production
static-assets-pages-i18n preview production
static-export preview production
web preview production

… route files in route preloads

- Export a null preload plan when the app registers @vitejs/plugin-rsc
  itself, which the SSR entry's import needs to build.
- Always preload the root not-found, forbidden and unauthorized
  boundaries, which routes without their own fall back to.
- Leave route files that are client references (every error.tsx) to their
  routes instead of preloading them everywhere.
- Skip awaiting a warm render's already loaded references.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk please review this PR at head 5ed0ee7. You have an 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@james-elicx
james-elicx marked this pull request as ready for review October 20, 2026 00:52
@clanky-the-clanker

Copy link
Copy Markdown

💬 Approved with comments — The per-route SSR preload plan, the runtime lookup and the call-site wiring look correct, and every fallback still loads all client references. The remaining notes are optional: test coverage for the rsc: false fallback and an AGENTS.md update.

Findings: 2 suggestions

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.

🔧 Fix with your agent

Paste the following into your coding agent to investigate and fix these issues.

Fix the following code review issues found in pull request #4114.

Before making changes, review each issue and present a brief summary table:
- For each finding, state whether you agree, disagree, or need clarification
- If you disagree (e.g., the fix would require disproportionate changes
  for minimal ROI, or the finding is factually incorrect), explain why
- If you need to clarify something before deciding, ask those questions
- Then share your plan for which issues to tackle and in what order

## SUGGESTION (2)

### [AGENTS.md] Consider extending the AGENTS.md client-reference guidance to describe route-scoped SSR preloading: where the plan is built and used, and that it stays on with `disableNextLikeChunking`.

### [Code Quality] The `rsc: false` fallback plugin, which now keeps the SSR entry's named import `routeClientReferences` resolvable, is never built in a test. Consider a build test or a unit test of the transform.
- **File:** packages/vinext/src/plugins/rsc-client-reference-loaders.ts:521

📖 AGENTS.md: 💬 1 suggestion

AGENTS.md could mention the new route-scoped SSR preload plan next to its client-reference grouping note.

Recommendation: 💬 Optional Nits

  • [SUGGESTION] Consider extending the AGENTS.md client-reference guidance to describe route-scoped SSR preloading: where the plan is built and used, and that it stays on with disableNextLikeChunking.
View full review
  • AGENTS.md's Performance Best Practices section describes production client-reference chunk grouping. This pull request adds a separate per-route SSR preload plan:
    • It is built in build/client-reference-groups.ts and plugins/rsc-client-reference-loaders.ts.
    • It is used in server/app-client-reference-preloader.ts and server/app-ssr-entry.ts.
    • Unlike grouping, it stays on with disableNextLikeChunking.
  • A short note on how the two differ would help future changes account for both.

📋 Code Quality: 💬 1 suggestion

The plan, runtime lookup and call-site wiring look correct. The rsc: false fallback that the SSR entry now depends on is never built in a test.

Recommendation: 💬 Optional Nits

  • [SUGGESTION] The rsc: false fallback plugin, which now keeps the SSR entry's named import routeClientReferences resolvable, is never built in a test. Consider a build test or a unit test of the transform. (packages/vinext/src/plugins/rsc-client-reference-loaders.ts:521)
View full review
  • No build test for the rsc: false fallback (packages/vinext/src/plugins/rsc-client-reference-loaders.ts:521-533, packages/vinext/src/index.ts:8926)
    • app-ssr-entry.ts:11 now imports the named export routeClientReferences from virtual:vite-rsc/client-references.
    • When an app registers @vitejs/plugin-rsc itself, only createRouteClientReferencesFallbackPlugin adds that export.
    • The fallback looks correct: it appends routeClientReferences = null in server environments, and null makes SSR preload every reference.
    • However, no production build exercises this configuration, and the fallback plugin has no unit test.
    • Suggestion: add a small build test with vinext({ rsc: false }) plus an explicit rsc(), or a unit test of the fallback's transform handler, so a future change can't break SSR builds for these apps unnoticed.
    • The fallback also appends its export during plugin-rsc's scan build, which the main loaders plugin skips. I found no evidence that this breaks anything.

Checked, no issue found

  • All four handleSsr callers pass the route pattern: page render, boundary render, ISR regeneration and the RSC render-status pass.
  • A PPR fallback shell, an unknown route pattern and a missing plan all fall back to preloading every reference.
  • The settled-promise marking (status/value) runs whether the load succeeds or fails, and a failed load still reaches onPreloadError.
  • The plan walk uses the same module-id canonicalization as the existing route-signature collection.

✅ All other checks passed

🔒 Security: ✅ LGTM

No security concerns:

  • Route patterns are looked up with Object.hasOwn, which avoids prototype-key lookups.
  • The generated manifest is emitted with JSON.stringify and holds only data computed at build time.

⚡ Performance: ✅ LGTM

No performance concerns. Each SSR render now preloads only its route's client references instead of every client reference in the app.


📝 Documentation: ✅ LGTM

No documentation issues found.


Reviewed commit: 5ed0ee71

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