Skip to content

Prefetch App Shells on the client#93999

Merged
acdlite merged 1 commit into
canaryfrom
acdlite/app-shells/03-client
May 26, 2026
Merged

Prefetch App Shells on the client#93999
acdlite merged 1 commit into
canaryfrom
acdlite/app-shells/03-client

Conversation

@acdlite
Copy link
Copy Markdown
Contributor

@acdlite acdlite commented May 20, 2026

Gated behind experimental.appShells. Clicking a link to a route the user has never specifically prefetched now renders the route's App Shell instantly; the per-link concrete request continues in the background and streams in the param-specific content.

Motivation

Today, prefetching a parameterized route like /chat/[id] requires a concrete id. The framework caches a separate prefetch entry per link, so the cost of prefetching scales with the number of visible links — not with the number of routes. On a page with high link cardinality (a feed, a search-results page, a chat list) it is impractical to prefetch every concrete URL up front. If a per-link prefetch for /chat/123 is still in flight when the user clicks the link, the navigation blocks until that prefetch completes; there's no cached generic shell to fall back to. The cost of a prefetch miss is a full blocking navigation.

The property we want: once a user has visited a Next.js app, every subsequent navigation should transition to something instantly — at minimum an App Shell — regardless of whether per-link prefetches for the concrete destination have completed. This matters most under adverse conditions (slow networks, offline, high-cardinality routes), but the guarantee is unconditional.

App Shells make that property hold. A shell is a per-route resource, not a per-link one — the number of shells in flight at any time scales with filesystem routes, which is bounded and small. Aggressive App Shell prefetching is affordable in a way that aggressive per-link runtime prefetching is not.

Mechanism

A new Shell phase sits in the prefetch scheduler between the existing RouteTree and Speculative (formerly Segments) phases. Shell-phase tasks issue an App Shell request and write the response under a param-independent vary path. Concurrent shell tasks for sibling links to the same route dedupe at this keypath, so the cache holds at most one shell entry per route.

The headline property: if N links on a page resolve to the same route under different params, they share one App Shell request collectively. Once it lands, every one of those navigations can render an instant shell, regardless of whether the param-specific concrete prefetch has completed.

The Speculative phase is mostly unchanged — it still issues the per-link concrete prefetches that fill in param-specific content over time. Routes that are fully static (no runtime data anywhere in their tree) skip the Shell phase entirely, since their existing static prefetches are already shell-like in shape.

Navigation-time cache lookup

A small change in how the cache is read at navigation time is what makes the instant-shell guarantee actually hold. The cache normally returns the most-specific matching entry — the right semantics for prefetch dedup, but the wrong semantics for navigation. If a fulfilled shell entry coexists with an in-flight Pending entry for a more-specific keypath that the navigation also matches, the most-specific entry is the empty Pending one, and the navigation would block on it instead of rendering the shell.

Navigation now does a two-pass lookup: first prefer Fulfilled entries anywhere along the vary path (so a less-specific shell beats a more-specific Pending), then fall back to the regular behavior if nothing fulfilled is found. Prefetch reads keep the original semantics, since they need to see in-flight entries to dedupe.

Scope

The shape of the prefetch request and the response interpretation differ between static (PPR) and runtime (PPRRuntime) prefetches. Only the runtime path is covered here. The static path uses a different strategy — rewinding a single response into a shell prefix and a concrete suffix, rather than issuing a separate shell-only request — and will be added in a future PR alongside the server-side byte-offset machinery it depends on.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 20, 2026

Tests Passed

Commit: 549fd28

@acdlite acdlite force-pushed the acdlite/app-shells/03-client branch from 1a6afb1 to c41a5b5 Compare May 20, 2026 23:22
@acdlite acdlite force-pushed the acdlite/app-shells/02-server branch 2 times, most recently from dbcb57d to d439c90 Compare May 20, 2026 23:37
@acdlite acdlite force-pushed the acdlite/app-shells/03-client branch from c41a5b5 to 491653d Compare May 20, 2026 23:37
@acdlite acdlite force-pushed the acdlite/app-shells/02-server branch from d439c90 to c829ac8 Compare May 21, 2026 03:19
@acdlite acdlite force-pushed the acdlite/app-shells/03-client branch from 491653d to a7f1afd Compare May 21, 2026 03:19
@acdlite acdlite force-pushed the acdlite/app-shells/02-server branch from c829ac8 to 2155d1a Compare May 21, 2026 16:08
@acdlite acdlite force-pushed the acdlite/app-shells/03-client branch from a7f1afd to 01fd595 Compare May 21, 2026 16:08
Base automatically changed from acdlite/app-shells/02-server to canary May 21, 2026 16:44
@acdlite acdlite force-pushed the acdlite/app-shells/03-client branch 2 times, most recently from b91ade3 to 408fadf Compare May 21, 2026 22:31
@acdlite acdlite marked this pull request as ready for review May 21, 2026 22:31
@acdlite acdlite requested review from lubieowoce and unstubbable May 21, 2026 22:33
@acdlite acdlite force-pushed the acdlite/app-shells/03-client branch from 408fadf to 1179507 Compare May 25, 2026 07:11
Comment thread packages/next/src/client/components/segment-cache/cache.ts
Comment thread packages/next/src/client/components/segment-cache/vary-path.ts Outdated
Gated behind `experimental.appShells`. Clicking a link to a route
the user has never specifically prefetched now renders the route's
App Shell instantly; the per-link concrete request continues in the
background and streams in the param-specific content.

### Motivation

Today, prefetching a parameterized route like `/chat/[id]` requires
a concrete `id`. The framework caches a separate prefetch entry per
link, so the cost of prefetching scales with the number of visible
links — not with the number of routes. On a page with high link
cardinality (a feed, a search-results page, a chat list) it is
impractical to prefetch every concrete URL up front. If a per-link
prefetch for `/chat/123` is still in flight when the user clicks
the link, the navigation blocks until that prefetch completes;
there's no cached generic shell to fall back to. The cost of a
prefetch miss is a full blocking navigation.

The property we want: once a user has visited a Next.js app, every
subsequent navigation should transition to _something_ instantly —
at minimum an App Shell — regardless of whether per-link prefetches
for the concrete destination have completed. This matters most
under adverse conditions (slow networks, offline, high-cardinality
routes), but the guarantee is unconditional.

App Shells make that property hold. A shell is a per-_route_
resource, not a per-link one — the number of shells in flight at
any time scales with filesystem routes, which is bounded and
small. Aggressive App Shell prefetching is affordable in a way
that aggressive per-link runtime prefetching is not.

### Mechanism

A new `Shell` phase sits in the prefetch scheduler between the
existing `RouteTree` and `Speculative` (formerly `Segments`)
phases. Shell-phase tasks issue an App Shell request and write the
response under a param-independent vary path. Concurrent shell
tasks for sibling links to the same route dedupe at this keypath,
so the cache holds at most one shell entry per route.

The headline property: if N links on a page resolve to the same
route under different params, they share _one_ App Shell request
collectively. Once it lands, every one of those navigations can
render an instant shell, regardless of whether the param-specific
concrete prefetch has completed.

The Speculative phase is mostly unchanged — it still issues the
per-link concrete prefetches that fill in param-specific content
over time. Routes that are fully static (no runtime data anywhere
in their tree) skip the Shell phase entirely, since their existing
static prefetches are already shell-like in shape.

### Navigation-time cache lookup

A small change in how the cache is read at navigation time is what
makes the instant-shell guarantee actually hold. The cache normally
returns the _most-specific_ matching entry — the right semantics
for prefetch dedup, but the wrong semantics for navigation. If a
fulfilled shell entry coexists with an in-flight Pending entry for
a more-specific keypath that the navigation also matches, the
most-specific entry is the empty Pending one, and the navigation
would block on it instead of rendering the shell.

Navigation now does a two-pass lookup: first prefer Fulfilled
entries anywhere along the vary path (so a less-specific shell
beats a more-specific Pending), then fall back to the regular
behavior if nothing fulfilled is found. Prefetch reads keep the
original semantics, since they need to see in-flight entries to
dedupe.

### Scope

The shape of the prefetch request and the response interpretation
differ between static (`PPR`) and runtime (`PPRRuntime`) prefetches.
Only the runtime path is covered here. The static path uses a
different strategy — rewinding a single response into a shell
prefix and a concrete suffix, rather than issuing a separate
shell-only request — and will be added in a future PR alongside
the server-side byte-offset machinery it depends on.
@acdlite acdlite force-pushed the acdlite/app-shells/03-client branch from 1179507 to 549fd28 Compare May 26, 2026 20:49
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 26, 2026

Stats cancelled

Commit: 549fd28
View workflow run

@acdlite acdlite merged commit 1eaa37e into canary May 26, 2026
164 of 165 checks passed
@acdlite acdlite deleted the acdlite/app-shells/03-client branch May 26, 2026 21:24
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.

2 participants