“Our dashboard takes four seconds to show anything. How would you make it feel fast without making the queries faster?” Interviewers use this question to see whether you know streaming in the App Router. Close behind come “What does loading.tsx actually do?”, “What happens when one widget throws?” and, from interviewers who have shipped Next.js, “Why did this missing product return a 200 instead of a 404?”
Those questions belong together because loading.tsx, Suspense, error.tsx and not-found.tsx are all boundaries in the same tree. Knowing where each boundary sits explains which content waits for which data, which failures are contained, and what HTTP status a crawler sees.
Before you start
You should know Server Components and async data fetching in the App Router, and what a React Suspense boundary and an error boundary are. A basic understanding of HTTP responses helps: headers, including the status code, are sent before the body. Examples use Next.js 15.5 with React 19.
The short answer
Streaming sends a page’s HTML in chunks: the parts that are ready go first, with fallbacks where data is still loading, and the rest streams into place as it resolves. loading.tsx is a shortcut for wrapping a segment’s page in a Suspense boundary with that fallback, so the layout and loading UI appear immediately; your own Suspense boundaries give finer control. error.tsx is a Client Component error boundary around the segment’s page and children, with a reset function to retry, and not-found.tsx renders when notFound() is called. Once streaming starts, the status code has already been sent, so decisions that need a 404 or redirect must happen before the first Suspense boundary.
How it works
For each route segment, Next nests the special files in a fixed order:
<Layout>
<Template>
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<NotFoundBoundary fallback={<NotFound />}>
<Page />
</NotFoundBoundary>
</Suspense>
</ErrorBoundary>
</Template>
</Layout>The layout sits outside the segment’s own error and loading boundaries. That has two consequences that interviewers test: an error thrown in layout.tsx is caught by the parent segment’s error.tsx, and a slow layout.tsx blocks everything below it, including the loading UI.
When React renders on the server and reaches a Suspense boundary whose children are still waiting, it emits the fallback HTML and moves on. The response stays open. When the data resolves, React sends the finished HTML along with a small inline script that swaps it into the fallback’s place. The browser can paint, and React can hydrate the ready parts, before the slow parts arrive.
// app/stream/page.tsx
import { Suspense } from 'react';
import { connection } from 'next/server';
async function Slow() {
await new Promise((r) => setTimeout(r, 2000)); // stands in for a slow query
return <p>SLOW READY</p>;
}
export default async function Page() {
await connection(); // dynamic route
return (
<main>
<h1>SHELL</h1>
<Suspense fallback={<p>LOADING SLOW</p>}>
<Slow />
</Suspense>
</main>
);
}Measured against next start, this page’s first byte arrived after about 7 ms, while the complete response took 2.01 seconds. Without the boundary, the first byte would wait the full two seconds.
Step-by-step walkthrough
Step 1: Add loading.tsx to the slow segment
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />; // same shape as the real page to avoid layout shift
}The layout and this skeleton are sent immediately, and Link prefetches dynamic routes down to this boundary, so a click shows the skeleton at once. Keep the layout itself free of slow awaits. In a test, a segment whose layout.tsx awaited a two-second call had a first byte of 2.04 seconds despite having loading.tsx, while the same delay in page.tsx streamed with a first byte of 14 ms.
Step 2: Split the page into independent Suspense boundaries
One loading.tsx still makes the whole page wait for its slowest query. Give each independent widget its own boundary, and start their queries without awaiting them one by one.
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<>
<Suspense fallback={<CardSkeleton />}><RevenueCard /></Suspense>
<Suspense fallback={<CardSkeleton />}><SignupsCard /></Suspense>
<Suspense fallback={<TableSkeleton />}><RecentOrders /></Suspense>
</>
);
}
async function RevenueCard() {
const revenue = await getRevenue(); // each component awaits only its own data
return <Card title="Revenue" value={revenue.total} />;
}Each card appears as soon as its own query finishes. Group boundaries when separate pop-ins would feel jumpy: one boundary around two small cards is fine.
Step 3: Contain failures with error.tsx
// app/dashboard/error.tsx
'use client';
import { useRouter } from 'next/navigation';
import { startTransition } from 'react';
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const router = useRouter();
return (
<div role="alert">
<p>Dashboard failed to load. Reference: {error.digest}</p>
<button onClick={() => startTransition(() => { router.refresh(); reset(); })}>Try again</button>
</div>
);
}error.tsx must be a Client Component; the build fails otherwise. In production, errors from Server Components reach it with a generic message and a digest, which you can match against server logs. reset() re-renders the boundary on the client. When the error came from server data, the retry also needs fresh server output, which is why the button calls router.refresh() too. A single error.tsx replaces the whole page segment. To keep the rest of the dashboard visible when one widget fails, wrap that widget in its own client error boundary, or move widgets into parallel route slots, each with its own error.tsx and loading.tsx.
Step 4: Decide 404s and redirects before streaming
// app/products/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
export default async function Product({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = await getProduct(slug); // awaited outside any Suspense boundary
if (!product) notFound(); // real 404 status
return (
<>
<ProductDetails product={product} />
<Suspense fallback={<p>Loading reviews</p>}><Reviews id={product.id} /></Suspense>
</>
);
}The existence check is cheap and decides the status, so it runs before the shell is sent. Slow, optional content streams afterwards.
Worked scenario
A product page was refactored for speed: the whole body, including the product lookup, moved inside a Suspense boundary so the header would paint immediately. Soon after, the SEO team reported that deleted products returned 200 and were being kept in the search index.
A test reproduced it. A page whose notFound() ran inside a Suspense boundary after half a second returned HTTP 200, with the not-found UI streamed into the body and a noindex robots meta tag added by Next. Users saw the right message, and the meta tag keeps well-behaved crawlers from indexing the page, but status-based monitoring, link checkers and caches all treated the page as a success. The response headers had been sent with the shell, before the lookup finished.
The fix was Step 4: await the product lookup at the top of the page, call notFound() there, and stream only the reviews and recommendations. The header still paints quickly because the lookup is a fast indexed query, and missing products return a real 404.
Common mistake
- “
loading.tsxmakes the whole segment instant.” It does not cover the same segment’s layout. Slow layout data blocks the first byte. - Awaiting everything at the top of the page. That recreates the waterfall streaming was meant to fix. Await only what decides the status, then stream.
- Expecting
error.tsxto catch its own layout. Layout errors go to the parent boundary, and root layout errors toglobal-error.tsx, which must render its ownhtmlandbody. - Throwing for expected outcomes. Validation failures and empty states should be rendered, not thrown into an error boundary.
- Calling
reset()alone after a server error. It retries rendering with the same server output; refresh the route as well.
Verify the behavior
Measure time to first byte against total time with curl, and check status codes for missing records.
npx next build && npx next start -p 3000 &
curl -s -o /dev/null -w 'ttfb %{time_starttransfer} total %{time_total}\n' localhost:3000/stream
# ttfb 0.007157 total 2.010744
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/products/deleted-item
# 404 when notFound() runs before streaming; 200 if it runs inside a Suspense boundaryIn the browser, the Network panel shows the document still downloading while the shell is already painted. Throttling the connection makes the chunks easy to see.
Follow-up questions
What is the difference between loading.tsx and a manual Suspense? loading.tsx wraps the whole page of a segment and is used for navigation prefetching; manual boundaries can wrap any component and give finer-grained streaming.
Does streaming help SEO? Crawlers receive the complete HTML once the stream ends, so content inside boundaries is still in the response. The benefit is faster first paint, not hidden content.
How does template.tsx interact with loading states? A template remounts on every navigation, so its children, including Suspense fallbacks, show again on each visit, while a layout would keep its previous content.
Can a Client Component suspend? Yes, with React use() on a promise passed from a Server Component, which lets the server start a request and the client display it inside a boundary.
Interview exercise
A dashboard page has a layout that loads the user’s organisation (100 ms), a revenue chart (3 seconds), a list of recent orders (400 ms) and a notifications badge (200 ms) in the header. Today the first byte arrives after about 3.7 seconds. Restructure it and estimate the new time to first byte.
Answer and reasoning
The 3.7 seconds suggests the queries run one after another and all block the response. Keep only the organisation lookup in the layout, because it decides access and whether to redirect, so the first byte arrives after roughly 100 ms plus render time. Move the notifications badge into its own Suspense boundary in the header so the layout does not wait for it. In the page, wrap the revenue chart and the orders list in separate boundaries with skeletons that match their final size. The orders appear at about 400 ms, the badge at about 200 ms, and the chart at about 3 seconds, without holding back anything else. Add an error boundary around the chart so a timeout in the slowest, least critical query cannot blank the dashboard. The reasoning interviewers want: await what decides the response, stream what users can wait for, and contain failures at the same granularity as loading.
Continue learning
Practise more rendering questions in the Next.js interview questions and the Next.js MCQs. Read React Suspense boundaries and loading ownership for the React model, and SSR vs SSG vs ISR in Next.js for when a route is dynamic in the first place. The official references are the Next.js 15 loading.js reference and the error handling guide.