Skip to content
instant.by-design
L0312 min

Why loading.tsx betrays you

Your loading.tsx and top-level boundaries now block the shell. The two-navigation validation model, the top-level await trap, and how to lock instant nav in.

You know Suspense. The placement rules changed.

Instant navigation reuses primitives you already have, Suspense, streaming, prefetch. What is new is a validation model that judges each boundary against two different navigations, and it turns some habits, especially loading.tsx, into anti-patterns.

loading.tsx is now usually wrong

A loading.tsx is a Suspense boundary at the top of the segment. Under Cache Components that boundary sits above your static and cached content and pulls all of it out of the shell, so the user gets a full-page skeleton instead of a shell. Push boundaries down to the single read they guard instead.

1

The habit and the fix

The whole-page skeleton was the point of loading.tsx. Now it is the failure mode: nothing static or cached can reach the first paint through it. Delete it, render the shell content directly, and wrap only the request read.

Anti-patterntsx
// app/dashboard/loading.tsx// You wrote this for years. It is a Suspense boundary at the TOP of the segment.export default function Loading() {  return <WholePageSkeleton />}
app/dashboard/page.tsxtsx
// app/dashboard/page.tsx  (no loading.tsx)export default function DashboardPage() {  return (    <Layout>      <Header />                      {/* static → shell */}      <Stats />                       {/* 'use cache' → shell */}      <Suspense fallback={<NotificationsSkeleton />}>        <Notifications />             {/* reads cookies() → the only thing that streams */}      </Suspense>    </Layout>  )}
Header and Stats reach the shell. Only the cookie-dependent panel streams in.
The hoist rule

If an element appears in both the fallback and the resolved result, hoist it above the boundary. A skeleton should stand in for data, not for your layout. And keep the largest element, usually the h1, out of every boundary so it paints at once.

2

The rule that hand-review misses: two navigations

The same route produces different first paints depending on how the user arrives, and a boundary can cover one path while leaving the other blocking. This is the core reason the framework validates for you.

One route, two arrivals
ArrivalWhat re-renders
Page load (direct visit / refresh)The full tree renders from the root. The static shell ships as HTML, often from a CDN.
Client navigation (in-app link click)Only the parts below the layout the two routes share re-render. Everything above is already mounted.

So a boundary in the root layout covers a page load, but on a client navigation between two siblings it sits above the re-render scope and never triggers. A boundary must live below the lowest layout the start and target routes share.

Predict first

A direct visit to /store/hats is instant, but clicking from /store/shoes to /store/hats blocks. The boundary is in the root layout. Why the difference?

useSearchParams is asymmetric now

useSearchParams() suspends on a page load (params unknown at build) but resolves synchronously on a client navigation (the router already has them). The same component blocks on one path and is instant on the other, which is exactly the kind of asymmetry the two-navigation check exists to catch.

3

The frequent blocker: a top-level await

The most common cause of a blocked shell is a top-level await in a layout or page, an auth gate being the classic. It runs before any child renders, holding the whole subtree out of the shell.

Blocks the shelltsx
// The classic blocker: a top-level await in a layout.export default async function Layout({ children }) {  const user = await getSession()   // request read, runs before any child  if (!user) redirect('/login')  return <Shell>{children}</Shell>  // whole subtree held out of the shell}
Do not blindly wrap a security gate

The mechanical fix is to move the read into a Suspense-wrapped child, but wrapping an auth gate can change what it guarantees. If the gate is load-bearing, move it to Proxy or a Data Access Layer, or keep the route instant = false on purpose. Fix pages too: a page-level await params blocks the same way.

4

Validation does the audit; then you lock it in

By default (validationLevel: 'warning') Cache Components validates every Page and Default segment in next dev, simulating both arrivals on each load. Insights land in the dev overlay, not the build, so a clean next build does not mean instant. Each insight offers three fixes.

The three fix cards
CardWhat it does
StreamWrap the read in Suspense so it streams behind a fallback.
CacheAdd 'use cache' so the result joins the shell.
BlockSet instant = false to allow the route to block on purpose.

Validation only proves a shell exists, not that the right content is in it (a boundary around the whole body passes with an empty shell). Lock the real content in with the instant() helper from @next/playwright, which scopes assertions to the UI present the moment navigation starts.

e2e/navigation.test.tstsx
import { test, expect } from '@playwright/test'import { instant } from '@next/playwright' test('product page is instant on a client navigation', async ({ page }) => {  await page.goto('/store/shoes')  await instant(page, async () => {    await page.click('a[href="/store/hats"]')    await page.waitForURL((url) => url.pathname === '/store/hats')  // wait for commit    await expect(page.locator('h1')).toContainText('Baseball Cap')  // in the shell    await expect(page.getByText('In stock')).toHaveCount(0)         // NOT in the shell  })  await expect(page.getByText('In stock')).toBeVisible()            // streamed after})
Inside the scope only the instant UI exists; after it, the dynamic part has streamed in. Test both arrivals.
Two more knobs

The Navigation Inspector in Next.js DevTools freezes the shell so you can see exactly what lands. To validate only segments that opt in, set validationLevel: 'manual-warning'; to run instant() against a production build, set exposeTestingApiInProductionBuild. (Instant assumes a warm cache; the first, cold navigation may still wait.)

Check yourself

A product page has a large title, a cached description, and a per-user 'recently viewed' strip. You currently ship a loading.tsx full-page skeleton. What is the correct structure?

What you learned

  • loading.tsx and top-level boundaries now pull content out of the shell.
  • Page load renders the whole tree; client navigation renders only below the shared layout.
  • A boundary must sit below the lowest layout the two routes share.
  • A top-level await blocks the subtree; move it or document a Block.
  • Dev validation finds the gaps; an instant() test proves the content.

Next: prefetching. The behavior of <Link> and prefetch={true} changed, and the old meaning can quietly regress your app.