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:
-
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).
-
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:
buildOutgoingAppPayload(element, layoutFlags) at app-page-render.ts:154 embeds __layoutFlags in the RSC wire payload before the tree starts streaming.
- 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.
Problem
Every App Router request renders each layout component twice — once for probing, once for the actual render:
app-page-execution.ts:164-233probeAppPageLayouts()renders layouts from leaf-to-root withchildren: nullapp-page-execution.ts:250-268probeAppPageComponent()renders the page separatelyapp-page-render.tsFor 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:
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 inlayoutFlags. Used downstream for navigation optimization (static layouts can be skipped on client-side navigation).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()andnotFound()from layouts/pages to early-exit before the full tree renders. Instead, catch these during the actual render pass:redirect()andnotFound()throw special objects that error boundaries can catch.probeLayoutForErrorslogic fromapp-page-execution.ts:235-248into the render error boundaries themselves — when a boundary catches a redirect/notFound, it callsonLayoutErrordirectly and aborts the tree.Step 2: Inline classification into the render pass
The probe runs each layout with
children: nullin an isolated scope to detect dynamic API usage. Instead:runWithIsolatedDynamicScope(same function used during probing atapp-page-execution.ts:198).headers(),cookies(), etc.) were accessed during that layout's render."s"/"d"flag immediately — no separate pass needed.app-page-execution.ts:174-176), skip the ALS wrap entirely — use the pre-computed flag.Step 3: Defer
layoutFlagsembeddingThis is the key architectural challenge. Currently:
buildOutgoingAppPayload(element, layoutFlags)atapp-page-render.ts:154embeds__layoutFlagsin the RSC wire payload before the tree starts streaming.Solution options:
Option A — Post-render payload patching (recommended):
__layoutFlagsmetadataapp-elements.ts:224),readAppElementsMetadata()already extractslayoutFlagsfrom the wire payload — update it to accept a post-render metadata chunkOption B — Deferred Promise slots:
"s"or"d"as it completesStep 4: Remove
probeAppPageComponententirelyThe 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:
probeAppPageLayoutsentirelyprobeAppPageComponententirelyprobeAppPageBeforeRenderfromapp-page-probe.tsapp-page-execution.tsto only export render utilities (no probe utilities)Files to change: 5-7
app-page-execution.tsapp-rsc-entry.ts:1822-1930app-page-probe.ts:30-84probeAppPageBeforeRender; if anything survives, inline it intoapp-page-render.tsapp-page-render.ts:149-157layoutFlagsembedding to post-render (Option A above); or accept flags produced during renderapp-elements.ts:220-240readAppElementsMetadatato handle post-render metadata chunkapp-ssr-entry.ts:170-210app-page-cache.tsKey behavior to preserve
layoutFlags["s"]tells the client "this layout hasn't changed, skip re-rendering it". This must still work.headers(),cookies(),draftMode(),connection()must still be detected per-layout for correct classification.Difficulty: Hard
Touches the core render lifecycle and the RSC wire format metadata ordering. The
layoutFlagsdeferral (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
layoutFlagsparsing must handle reordered metadata. If the client expects flags in the first chunk, this breaks without client-side changes.