Skip to content

perf: eliminate redundant layout/page probing by merging into single render pass #982

@Divkix

Description

@Divkix

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions