Prefetch App Shells on the client#93999
Merged
Merged
Conversation
Contributor
Tests PassedCommit: 549fd28 |
1a6afb1 to
c41a5b5
Compare
dbcb57d to
d439c90
Compare
c41a5b5 to
491653d
Compare
d439c90 to
c829ac8
Compare
491653d to
a7f1afd
Compare
c829ac8 to
2155d1a
Compare
a7f1afd to
01fd595
Compare
b91ade3 to
408fadf
Compare
408fadf to
1179507
Compare
unstubbable
approved these changes
May 26, 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.
1179507 to
549fd28
Compare
Contributor
Stats cancelledCommit: 549fd28 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 concreteid. 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/123is 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
Shellphase sits in the prefetch scheduler between the existingRouteTreeandSpeculative(formerlySegments) 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.