skip to content

A Next.js App Router route has a loading.tsx, but its layout.tsx awaits a slow API call before rendering. Nothing at all reaches the browser for two seconds — not even the skeleton. Why does loading.tsx not help here, and how would you fix it?

level: seniorimportance: should knowfreq 48%

answer

  1. the symptom is TTFB, not paint
  2. check what sits above the boundary
  3. layout wraps loading, not the reverse
  4. a boundary defers only what is below it
  5. extract the await into a suspended child

basics

~20 s

Next.js nests the boundary created by loading.tsx inside layout.tsx, so the layout renders above the fallback. Work awaited in the layout blocks the shell itself. Move that fetch into a child component wrapped in its own <Suspense> inside the layout.

solid answer

~50 s

`loading.tsx` is a Suspense boundary at a fixed position: under the segment's layout, over its page. Anything the framework must render *above* that boundary has to complete before the shell can be flushed — and `layout.tsx` is above it. So a slow `await` in the layout blocks the whole response, skeleton included, and the browser waits with nothing. The fix is to get the slow work below a boundary. Keep the layout's own render synchronous and extract the slow part into an async child component, wrapped in `<Suspense>` inside the layout; then the layout's chrome plus that fallback plus the page's `loading.tsx` fallback all flush immediately. If the data is really page-level rather than layout-level, move the fetch to the page or below. The general rule to state out loud: a boundary can only defer work beneath it, so trace upward from the boundary to find whatever is still blocking.

code

tsx · 30 lines
tsx
import { Suspense } from 'react';

async function Nav() {
  const res = await fetch('https://example.com/api/nav');
  const items: { href: string; label: string }[] = await res.json();
  return (
    <ul>
      {items.map((i) => (
        <li key={i.href}>
          <a href={i.href}>{i.label}</a>
        </li>
      ))}
    </ul>
  );
}

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <section>
      <Suspense fallback={<p>Loading nav…</p>}>
        <Nav />
      </Suspense>
      <main>{children}</main>
    </section>
  );
}

go deeper

for a junior

Remember the nesting order: the layout is outside, the loading.tsx boundary is inside it. Slow work in a layout therefore delays everything, skeleton included.

for a middle

Explain why an async layout produces no output until its await resolves, so no shell exists to flush, and show the refactor that moves the fetch into a suspended child component.

for a senior

Diagnose from evidence: distinguish a slow TTFB, which means something above every boundary is blocking, from a fast TTFB with a late section, which means streaming is working. Then walk the ancestor chain to the highest blocking await.

for a principal

Own the decision of whether to stream around slow shell data at all — weigh painting provisional chrome against waiting, and push toward making the dependency cheap rather than treating boundaries as the universal answer.

## The nesting decides everything For a segment, Next.js assembles the conventional files in a fixed order. Simplified: ```tsx <Layout> {/* layout.tsx — ABOVE the boundary */} <Suspense fallback={<Loading />}> {/* loading.tsx */} <Page /> {/* page.tsx and nested segments */} </Suspense> </Layout> ``` Read it top-down and the symptom explains itself. To emit the `<Suspense>` element at all, the server must first render `<Layout>`. If `Layout` is an async component that `await`s a slow call, it produces no output until that call resolves — and with no output there is no shell to flush and no fallback markup to send. The user gets a hanging request rather than a skeleton. This is the single most common "streaming isn't working" report in App Router codebases, and it generalizes: root layout, any intermediate layout in a nested chain, or anything else rendered above the boundary can block. `loading.tsx` protects everything under it and nothing over it. ## Diagnosing it The give-away is time-to-first-byte, not paint. If TTFB itself is ~2s, no boundary anywhere below can be responsible — the server had not committed a single byte. If TTFB is fast but a section is empty for two seconds, streaming is working and you are looking at a boundary that is doing its job. From there, walk up from the boundary and ask what each ancestor awaits: the segment's layout, its parent layouts up to the root layout, and any async work the framework must resolve before it can begin the body. The first blocking `await` you find on that path is the cause. ## The fix: push the await below a boundary ```tsx // app/dashboard/layout.tsx — before: blocks the shell export default async function Layout({ children }: { children: React.ReactNode }) { const nav = await getNavigation(); // ⛔ 2s, above every boundary return <section><Nav items={nav} /><main>{children}</main></section>; } // after: layout renders immediately, nav streams in import { Suspense } from 'react'; async function Nav() { const items = await getNavigation(); // now under a boundary return <ul>{items.map((i) => <li key={i.href}>{i.label}</li>)}</ul>; } export default function Layout({ children }: { children: React.ReactNode }) { return ( <section> <Suspense fallback={<NavSkeleton />}><Nav /></Suspense> <main>{children}</main> </section> ); } ``` The layout function is now synchronous, so it returns JSX on the first pass. The shell — section, nav skeleton, and the page's `loading.tsx` fallback — flushes right away, and the navigation markup streams in when `getNavigation` resolves. ## When streaming is the wrong answer Sometimes the blocking `await` is there because the layout genuinely cannot render without it — a shell that depends on the tenant's theme or the user's permissions, where painting the wrong chrome and correcting it is worse than waiting. In that case boundaries are not the tool. The realistic options are to make the call fast (cache it, colocate it, precompute it), to move the decision to a layer that resolves before render, or to accept the blocking cost as a deliberate choice. State that tradeoff explicitly rather than reflexively reaching for `<Suspense>`; an interviewer is often probing whether you know streaming has a scope. Also worth naming: pushing a fetch below a boundary does not serialize it behind other work. Requests made in sibling subtrees can be in flight concurrently, and identical requests during one render are typically deduplicated by the framework's request-scoped caching — so the restructure is usually free in wall-clock terms, not a second round trip. ## The sentence to leave the interviewer with "A Suspense boundary can only defer what is beneath it. `loading.tsx` sits under the layout, so it can never rescue a layout that blocks — find the highest blocking `await` on the path from the boundary to the root, and either move it down or make it fast."

  • How would you tell from browser devtools whether the problem is above or below the boundary?
    Look at time-to-first-byte for the document. A slow TTFB means the server committed nothing, so the blocker is above every boundary — no amount of in-page Suspense will help. A fast TTFB with a section empty for seconds means streaming is working and you are watching a boundary behave correctly.
  • Does moving the layout's fetch into a suspended child cost an extra round trip?
    No. The request simply starts a little later in the same render, and sibling subtrees can have their requests in flight concurrently. Identical requests within one render are typically deduplicated by the framework's request-scoped caching, so the restructure does not duplicate work.
  • When would you deliberately keep the blocking await in the layout?
    When the chrome is meaningless or wrong without that data — tenant theming, permission-driven navigation — and correcting it after paint would be worse than a slower first byte. Then the real work is making the call cheap: caching it, colocating it, or resolving it before render rather than streaming around it.

saying these in an interview costs you the question

  • Says loading.tsx covers the layout as well as the page
  • Adds more in-page boundaries when TTFB itself is the problem
  • Blames the Suspense fallback for not rendering fast enough
  • Thinks moving a fetch below a boundary adds a second round trip
  • Assumes every blocking await should be streamed around

context