[GH-ISSUE #982] perf: eliminate redundant layout/page probing by merging into single render pass #211

Closed
opened 2026-05-06 12:38:13 +02:00 by BreizhHardware · 1 comment

Originally created by @Divkix on GitHub (Apr 30, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/982

Problem

Every App Router request renders each layout component twice — once for probing, once for the actual render:

Phase File:Line What
Layout probe app-page-execution.ts:164-233 probeAppPageLayouts() renders layouts from leaf-to-root with children: null
Page probe app-page-execution.ts:250-268 probeAppPageComponent() renders the page separately
Actual render app-page-render.ts Full React tree render (layouts + page + children)

For a route with 5 nested layouts, that's 5 extra React render + commit cycles per request. The probes are sequential (leaf-to-root), cannot be parallelized, and happen on every single request — even though a layout's classification (static vs dynamic) rarely changes between requests.

What probing does

The probe serves two purposes:

  1. Classification (app-page-execution.ts:194-221): Run the layout in an isolated dynamic scope to detect whether it uses dynamic APIs (headers(), cookies(), etc.). Result: "s" (static) or "d" (dynamic) stored in layoutFlags. Used downstream for navigation optimization (static layouts can be skipped on client-side navigation).

  2. Error detection (app-page-execution.ts:235-248): Catch redirects and notFounds from layout/page rendering before the full render starts. Allows early-exit without building the full React tree.

Approach: Single-pass rendering

Step 1: Eliminate the error-detection probe

The probe catches redirect() and notFound() from layouts/pages to early-exit before the full tree renders. Instead, catch these during the actual render pass:

  • React error boundaries already exist in the layout tree (each layout is wrapped). redirect() and notFound() throw special objects that error boundaries can catch.
  • Move probeLayoutForErrors logic from app-page-execution.ts:235-248 into the render error boundaries themselves — when a boundary catches a redirect/notFound, it calls onLayoutError directly and aborts the tree.
  • This eliminates the need to render layouts twice just to catch errors. If a layout throws, the error boundary handles it during the real render.

Step 2: Inline classification into the render pass

The probe runs each layout with children: null in an isolated scope to detect dynamic API usage. Instead:

  • During the real render, wrap each layout in an ALS scope via runWithIsolatedDynamicScope (same function used during probing at app-page-execution.ts:198).
  • The ALS scope tracks whether dynamic APIs (headers(), cookies(), etc.) were accessed during that layout's render.
  • When the layout completes, record the "s"/"d" flag immediately — no separate pass needed.
  • For build-time classified layouts (app-page-execution.ts:174-176), skip the ALS wrap entirely — use the pre-computed flag.

Step 3: Defer layoutFlags embedding

This is the key architectural challenge. Currently:

  1. buildOutgoingAppPayload(element, layoutFlags) at app-page-render.ts:154 embeds __layoutFlags in the RSC wire payload before the tree starts streaming.
  2. In the single-pass model, flags aren't available until after the tree renders.

Solution options:

Option A — Post-render payload patching (recommended):

  • Start the RSC stream without layoutFlags embedded
  • After the tree finishes rendering (all layouts complete), emit a final chunk containing __layoutFlags metadata
  • On the client side (app-elements.ts:224), readAppElementsMetadata() already extracts layoutFlags from the wire payload — update it to accept a post-render metadata chunk
  • This requires the client RSC payload parser to handle metadata arriving after the tree (may already work if metadata is a separate chunk)

Option B — Deferred Promise slots:

  • Pre-allocate slot indices for each layout's flag in the payload
  • During render, each layout fills its slot with "s" or "d" as it completes
  • Requires a mutable payload structure that can be patched mid-stream

Step 4: Remove probeAppPageComponent entirely

The page component probe (app-page-execution.ts:250-268) renders the page to catch errors. Same fix as Step 1: catch errors during the real render via the page's error boundary.

Step 5: Clean up dead code

After the above is working:

  • Remove probeAppPageLayouts entirely
  • Remove probeAppPageComponent entirely
  • Remove probeAppPageBeforeRender from app-page-probe.ts
  • Simplify app-page-execution.ts to only export render utilities (no probe utilities)

Files to change: 5-7

File Change
app-page-execution.ts Remove probe functions; add inline classification helpers for use during render
app-rsc-entry.ts:1822-1930 Refactor probe closures into render-inline error handlers; wire ALS scope wrapping for classification
app-page-probe.ts:30-84 Remove probeAppPageBeforeRender; if anything survives, inline it into app-page-render.ts
app-page-render.ts:149-157 Defer layoutFlags embedding to post-render (Option A above); or accept flags produced during render
app-elements.ts:220-240 Update readAppElementsMetadata to handle post-render metadata chunk
app-ssr-entry.ts:170-210 May need update to handle reordered RSC metadata in SSR embed transform
app-page-cache.ts Verify cache write still receives correct flags

Key behavior to preserve

  • Static layout skipping on navigation: layoutFlags["s"] tells the client "this layout hasn't changed, skip re-rendering it". This must still work.
  • Redirect/notFound early-exit: If a layout redirects, the response must be a redirect — not a partially-rendered page. Error boundaries during render achieve this.
  • Dynamic API detection: headers(), cookies(), draftMode(), connection() must still be detected per-layout for correct classification.
  • Build-time classification still works: For layouts classified at build time, skip all runtime detection (fast path, no ALS wrap).

Difficulty: Hard

Touches the core render lifecycle and the RSC wire format metadata ordering. The layoutFlags deferral (Step 3) is the trickiest part.

Expected improvement

20-40% faster SSR for routes with 5+ nested layouts. Every layout-heavy page gets this benefit. Even single-layout routes benefit from skipping the page component probe.

Risk: Medium-High

  • Error boundary semantics must be verified — the probe currently catches errors BEFORE any tree output. Moving to inline error boundaries means errors are caught during render which could produce partial output if not handled carefully.
  • Client-side layoutFlags parsing must handle reordered metadata. If the client expects flags in the first chunk, this breaks without client-side changes.
  • Must test: redirects from layouts, notFound from pages, parallel routes, intercepting routes.
Originally created by @Divkix on GitHub (Apr 30, 2026). Original GitHub issue: https://github.com/cloudflare/vinext/issues/982 ### Problem Every App Router request renders each layout component **twice** — once for probing, once for the actual render: | Phase | File:Line | What | |-------|-----------|------| | Layout probe | `app-page-execution.ts:164-233` | `probeAppPageLayouts()` renders layouts from leaf-to-root with `children: null` | | Page probe | `app-page-execution.ts:250-268` | `probeAppPageComponent()` renders the page separately | | Actual render | `app-page-render.ts` | Full React tree render (layouts + page + children) | For a route with 5 nested layouts, that's 5 extra React render + commit cycles per request. The probes are sequential (leaf-to-root), cannot be parallelized, and happen on every single request — even though a layout's classification (static vs dynamic) rarely changes between requests. ### What probing does The probe serves two purposes: 1. **Classification** (`app-page-execution.ts:194-221`): Run the layout in an isolated dynamic scope to detect whether it uses dynamic APIs (`headers()`, `cookies()`, etc.). Result: `"s"` (static) or `"d"` (dynamic) stored in `layoutFlags`. Used downstream for navigation optimization (static layouts can be skipped on client-side navigation). 2. **Error detection** (`app-page-execution.ts:235-248`): Catch redirects and notFounds from layout/page rendering before the full render starts. Allows early-exit without building the full React tree. ### Approach: Single-pass rendering #### Step 1: Eliminate the error-detection probe The probe catches `redirect()` and `notFound()` from layouts/pages to early-exit before the full tree renders. Instead, catch these during the actual render pass: - React error boundaries **already exist** in the layout tree (each layout is wrapped). `redirect()` and `notFound()` throw special objects that error boundaries can catch. - Move `probeLayoutForErrors` logic from `app-page-execution.ts:235-248` into the render error boundaries themselves — when a boundary catches a redirect/notFound, it calls `onLayoutError` directly and aborts the tree. - This eliminates the need to render layouts twice just to catch errors. If a layout throws, the error boundary handles it during the real render. #### Step 2: Inline classification into the render pass The probe runs each layout with `children: null` in an isolated scope to detect dynamic API usage. Instead: - During the real render, wrap each layout in an ALS scope via `runWithIsolatedDynamicScope` (same function used during probing at `app-page-execution.ts:198`). - The ALS scope tracks whether dynamic APIs (`headers()`, `cookies()`, etc.) were accessed during that layout's render. - When the layout completes, record the `"s"`/`"d"` flag immediately — no separate pass needed. - For build-time classified layouts (`app-page-execution.ts:174-176`), skip the ALS wrap entirely — use the pre-computed flag. #### Step 3: Defer `layoutFlags` embedding This is the key architectural challenge. Currently: 1. `buildOutgoingAppPayload(element, layoutFlags)` at `app-page-render.ts:154` embeds `__layoutFlags` in the RSC wire payload **before** the tree starts streaming. 2. In the single-pass model, flags aren't available until **after** the tree renders. **Solution options:** **Option A — Post-render payload patching (recommended):** - Start the RSC stream without layoutFlags embedded - After the tree finishes rendering (all layouts complete), emit a final chunk containing `__layoutFlags` metadata - On the client side (`app-elements.ts:224`), `readAppElementsMetadata()` already extracts `layoutFlags` from the wire payload — update it to accept a post-render metadata chunk - This requires the client RSC payload parser to handle metadata arriving after the tree (may already work if metadata is a separate chunk) **Option B — Deferred Promise slots:** - Pre-allocate slot indices for each layout's flag in the payload - During render, each layout fills its slot with `"s"` or `"d"` as it completes - Requires a mutable payload structure that can be patched mid-stream #### Step 4: Remove `probeAppPageComponent` entirely The page component probe (`app-page-execution.ts:250-268`) renders the page to catch errors. Same fix as Step 1: catch errors during the real render via the page's error boundary. #### Step 5: Clean up dead code After the above is working: - Remove `probeAppPageLayouts` entirely - Remove `probeAppPageComponent` entirely - Remove `probeAppPageBeforeRender` from `app-page-probe.ts` - Simplify `app-page-execution.ts` to only export render utilities (no probe utilities) ### Files to change: 5-7 | File | Change | |------|--------| | `app-page-execution.ts` | Remove probe functions; add inline classification helpers for use during render | | `app-rsc-entry.ts:1822-1930` | Refactor probe closures into render-inline error handlers; wire ALS scope wrapping for classification | | `app-page-probe.ts:30-84` | Remove `probeAppPageBeforeRender`; if anything survives, inline it into `app-page-render.ts` | | `app-page-render.ts:149-157` | Defer `layoutFlags` embedding to post-render (Option A above); or accept flags produced during render | | `app-elements.ts:220-240` | Update `readAppElementsMetadata` to handle post-render metadata chunk | | `app-ssr-entry.ts:170-210` | May need update to handle reordered RSC metadata in SSR embed transform | | `app-page-cache.ts` | Verify cache write still receives correct flags | ### Key behavior to preserve - **Static layout skipping on navigation**: `layoutFlags["s"]` tells the client "this layout hasn't changed, skip re-rendering it". This must still work. - **Redirect/notFound early-exit**: If a layout redirects, the response must be a redirect — not a partially-rendered page. Error boundaries during render achieve this. - **Dynamic API detection**: `headers()`, `cookies()`, `draftMode()`, `connection()` must still be detected per-layout for correct classification. - **Build-time classification still works**: For layouts classified at build time, skip all runtime detection (fast path, no ALS wrap). ### Difficulty: Hard Touches the core render lifecycle and the RSC wire format metadata ordering. The `layoutFlags` deferral (Step 3) is the trickiest part. ### Expected improvement **20-40% faster SSR** for routes with 5+ nested layouts. Every layout-heavy page gets this benefit. Even single-layout routes benefit from skipping the page component probe. ### Risk: Medium-High - Error boundary semantics must be verified — the probe currently catches errors BEFORE any tree output. Moving to inline error boundaries means errors are caught during render which could produce partial output if not handled carefully. - Client-side `layoutFlags` parsing must handle reordered metadata. If the client expects flags in the first chunk, this breaks without client-side changes. - Must test: redirects from layouts, notFound from pages, parallel routes, intercepting routes.
Author
Owner

@Divkix commented on GitHub (Apr 30, 2026):

Closing for now. This is a high-complexity architectural change affecting the core render lifecycle and RSC wire format metadata ordering. The complexity/risk outweighs the 20-40% SSR performance gain for routes with 5+ nested layouts at this stage of the project.

We'll revisit this optimization before v1.0 when we have more test coverage and stability in the App Router render pipeline.

cc @divkix

<!-- gh-comment-id:4349563855 --> @Divkix commented on GitHub (Apr 30, 2026): Closing for now. This is a high-complexity architectural change affecting the core render lifecycle and RSC wire format metadata ordering. The complexity/risk outweighs the 20-40% SSR performance gain for routes with 5+ nested layouts at this stage of the project. We'll revisit this optimization before v1.0 when we have more test coverage and stability in the App Router render pipeline. cc @divkix
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#211
No description provided.