skip to content

A React page uses streaming server rendering and Suspense boundaries deep in the tree, yet time-to-first-byte is still ~900 ms because a top-level component above every boundary awaits a slow API before rendering. Explain why the deeper boundaries do not help, and what actually fixes it.

level: seniorimportance: should knowfreq 45%

answer

  1. nothing flushes until the shell is ready
  2. shell = tree minus suspended subtrees
  3. the await sits above every boundary
  4. boundary must contain the wait
  5. start the promise early, read it inside

basics

~20 s

Nothing can be flushed until the shell is renderable, and the shell is everything outside pending Suspense boundaries — including that top-level await. Boundaries below it are irrelevant. The fix is to move the slow work inside a boundary, or start the request early and pass the unawaited promise down to a suspended child.

solid answer

~50 s

React cannot flush the first chunk until the shell is ready, and the shell is defined as the whole tree minus the subtrees currently inside a pending `<Suspense>`. An `await` in a component that sits above every boundary is part of the shell, so the server sits on the response for the full 900 ms and every deeper boundary is moot — they only control what happens *after* the first flush. Two fixes, and the distinction matters: move the slow work *into* a component rendered inside a boundary, so suspending there leaves the shell intact; or, if the request must start early for waterfall reasons, kick it off at the top without awaiting and pass the promise down to a child inside a boundary that reads it. Awaiting high and passing the resolved data down keeps the shell blocked no matter how many boundaries you add — the boundary must contain the wait, not just the consumer.

go deeper

for a junior

Remember that a Suspense boundary only helps for work that happens inside it; an await in a parent above every boundary still holds the whole response.

for a middle

Explain the shell definition and walk the tree from root to first boundary to find the blocking await, then describe moving the awaiting component inside a boundary.

for a senior

Show diagnosis and repair under production conditions: identify the blocking dependency from response timing, choose between relocating the await and passing an unawaited promise down, and avoid the trap of a too-high boundary.

for a principal

Set the standard for what belongs in a shell — session, flags, layout, above-the-fold content — and hold a latency budget for it, since every shell dependency is permanently on the first-byte critical path for that route.

## The rule being violated Streaming has exactly one gate on the first byte: **the shell must be renderable.** The shell is the tree minus anything inside a currently pending Suspense boundary. React renders top-down; when it hits a suspended component it checks for an enclosing boundary. If there is one, it emits the fallback and continues. If there is none — because the suspension happened above every boundary — the shell itself is incomplete, and there is nothing to flush yet. So an `await` in a top-level component is a hard block. The response has not started; the browser has not even seen `<head>`, so it cannot begin fetching CSS or JS. All the careful boundary placement below is dead weight, because boundaries govern the *second* chunk onward. ## Diagnosing it The symptom pattern is distinctive: TTFB equals the slowest server dependency, but once bytes start flowing the rest of the page fills in quickly. Confirm it by watching the response arrive over time (`curl -N` on the URL) — a blocked shell shows a long silence and then a burst. From there, walk the tree from the root down to the first `<Suspense>` and list everything that awaits: any data access, any component that suspends, and also anything a framework runs above your first boundary. ## Fix 1 — put the wait inside a boundary The direct fix is to relocate the awaiting code into a component that is rendered as a child of a boundary: ```jsx // before: blocks the shell async function Page() { const data = await slowApi(); return <Layout><Panel data={data} /></Layout>; } // after: shell flushes immediately function Page() { return ( <Layout> <Suspense fallback={<PanelSkeleton />}> <Panel /> {/* awaits slowApi() itself */} </Suspense> </Layout> ); } ``` Now `Page` and `Layout` render synchronously, `Panel` suspends, its fallback is emitted, and the shell goes out at once. The subtle mistake is a half-move: keeping `const data = await slowApi()` in `Page` and merely wrapping `<Panel data={data} />` in a boundary. The boundary catches suspensions raised by its *children during render*; the parent already blocked before those children were reached. **The boundary must contain the wait, not just the consumer.** ## Fix 2 — start early, await late Sometimes you genuinely want the request to start at the top — for example to avoid a waterfall where two children each start their own dependent fetch. The pattern is to call the async function without awaiting it and pass the promise down: ```jsx function Page() { const dataPromise = slowApi(); // started, not awaited return ( <Layout> <Suspense fallback={<PanelSkeleton />}> <Panel dataPromise={dataPromise} /> {/* reads it, suspends here */} </Suspense> </Layout> ); } ``` The network request is in flight from the earliest possible moment, but the *waiting* happens inside the boundary, so the shell is unaffected. Attach a rejection handler where appropriate so an early failure does not surface as an unhandled rejection before anything reads the promise. ## What else can block a shell - **A boundary that is too high.** Wrapping the entire page in one `<Suspense>` technically prevents a blocked shell, but then the shell is nearly empty and the user gets a full-page skeleton — you have traded a slow TTFB for a blank paint. The goal is a *useful* shell: layout, navigation, headings, above-the-fold content. - **Sequential awaits in the shell.** Two independent shell-level fetches awaited one after the other add up. If they must stay in the shell, start both first and await them together. - **Shared setup above the boundary** — session lookup, feature flags, config — that every request needs. This is often legitimately shell work; make it fast or cache it rather than hiding it behind a boundary, since the page usually cannot render meaningfully without it. ## Judgment to show The interviewer is testing whether you understand that boundaries are *flush points after the shell*, not a general "make this async" annotation. The strong answer names the shell definition, points at the specific await, and distinguishes the two fixes — including why passing already-awaited data down does nothing. A good closer is the tradeoff: shrink the shell to what the user must see immediately, but not so far that the first paint is all skeletons.

  • Would wrapping the consuming child in a Suspense boundary while keeping the await in the parent fix the TTFB?
    No. A boundary catches suspensions raised while rendering its children. If the parent awaits first, React never reaches those children — the render is blocked above the boundary and the shell is still incomplete. The awaiting code itself has to move inside the boundary, or the parent must pass an unawaited promise down.
  • Why not just wrap the entire page in one Suspense boundary so nothing can ever block the shell?
    Then the shell contains almost nothing and the user's first paint is a full-page skeleton — you have converted slow TTFB into fast-but-empty. The point of streaming is a shell that is already useful: layout, navigation, and above-the-fold content, with boundaries only around genuinely slow, independent regions.
  • How would you confirm from outside the app that the shell is what is blocking?
    Request the page with a client that shows chunk timing, such as curl -N, and watch the arrival pattern. A blocked shell means a long silence followed by a burst of output. If the first bytes arrive quickly and later chunks trickle in, the shell is fine and the latency lives in the boundaries instead.
  • Two independent slow calls must both stay in the shell. What is the minimum you should do?
    Start both before awaiting either, then await them together, so the shell cost is the slower of the two rather than their sum. Beyond that, the real lever is making them fast — cache them, colocate them with the server, or reduce them to one call — because shell work is by definition on the first-byte critical path.

saying these in an interview costs you the question

  • Thinks any Suspense boundary anywhere unblocks the first flush
  • Says wrapping the consumer suffices while the parent awaits
  • Blames the network instead of a shell-level await
  • Proposes wrapping the whole page in one boundary
  • Claims streaming has no effect on time-to-first-byte at all

context