Stream content progressively with Suspense to improve perceived performance and UX.
import { Suspense } from "react";
import SlowComponent from "./SlowComponent";
export default function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<SlowComponent />
</Suspense>
);
}loading.tsx file as a route-level Suspense boundary.// app/dashboard/loading.tsx
export default function Loading() {
return <div className="skeleton">Loading dashboard...</div>;
}// Any async server component inside Suspense is streamed
async function SlowData() {
const data = await fetchSlowAPI(); // streamed when ready
return <ul>{data.map(d => <li key={d.id}>{d.name}</li>)}</ul>;
}export default function Dashboard() {
return (
<div>
<Header />
<Suspense fallback={<p>Loading posts...</p>}>
<Posts />
</Suspense>
<Suspense fallback={<p>Loading sidebar...</p>}>
<Sidebar />
</Suspense>
</div>
);
}<Suspense fallback={<HeaderSkeleton />}>
<Header />
<Suspense fallback={<FeedSkeleton />}>
<Feed />
</Suspense>
</Suspense>// app/page.tsx
import { Suspense } from "react";
import Header from "./components/Header";
import Post from "./components/Post";
export default function Home() {
return (
<>
<Header />
<Suspense fallback={<p>Loading posts...</p>}>
<Post />
</Suspense>
</>
);
}
// components/Post.tsx (server component)
async function Post() {
const posts = await fetch("https://api/posts").then(r => r.json());
return posts.map(post => <article key={post.id}>{post.title}</article>);
}async function Page() {
// Both fetches start at the same time
const [user, posts] = await Promise.all([
fetchUser(),
fetchPosts()
]);
return <Profile user={user} posts={posts} />;
}error.tsx so a thrown component swaps its fallback for an error UI instead of crashing the page.// app/dashboard/error.tsx — catches errors thrown by streamed children
"use client";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<p>Failed to load this section.</p>
<button onClick={reset}>Retry</button>
</div>
);
}
<Suspense> boundary already streamed to the client gets replaced in place by the nearest error boundary's UI.async function Reviews({ id }) {
const reviews = await fetchReviews(id); // throws on network failure
return <ul>{reviews.map(r => <li key={r.id}>{r.text}</li>)}</ul>;
}
// If Reviews throws after streaming starts, error.tsx output
// replaces only the Reviews chunk -- sibling boundaries stay intact.
try/catch inside an async Server Component to return a fallback value instead of throwing, when inline UI is better than delegating to error.tsx.async function Reviews({ id }) {
try {
const reviews = await fetchReviews(id);
return <ReviewList reviews={reviews} />;
} catch {
return <p>Reviews are unavailable right now.</p>; // no error boundary triggered
}
}
error.tsx at the same route segment as the Suspense boundary it should protect — a parent segment's error.tsx also catches throws from streamed children further down.export default function Product({ id }) {
return (
<div>
<Suspense fallback={<p>Loading product...</p>}>
<BasicProduct id={id} />
</Suspense>
<Suspense fallback={<p>Loading reviews...</p>}>
<Reviews id={id} />
</Suspense>
<Suspense fallback={<p>Loading recommendations...</p>}>
<Recommendations id={id} />
</Suspense>
</div>
);
}
<main>
<HeroSection /> {/* renders synchronously, no Suspense */}
<Suspense fallback={<Skeleton />}>
<BelowFoldContent /> {/* streams in after hero */}
</Suspense>
</main>
async function withTimeout(promise, ms, fallback) {
const timeout = new Promise(resolve => setTimeout(() => resolve(fallback), ms));
return Promise.race([promise, timeout]);
}
import { useTransition } from "react";
export default function Page({ id }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
function handleNavigate() {
startTransition(() => {
router.push(`/product/${id}`);
});
}
return (
<>
{isPending && <p>Loading...</p>}
<button onClick={handleNavigate}>
View Product
</button>
</>
);
}const [isPending, startTransition] = useTransition();
function handleFilter(value) {
setInputValue(value); // urgent: update input immediately
startTransition(() => {
setFilteredList(filterItems(value)); // non-urgent: can wait
});
}const deferredQuery = useDeferredValue(query);
// deferredQuery lags behind query, preventing expensive re-renders
return <Results query={deferredQuery} />;Streaming sends the initial HTML shell immediately, then flushes chunks as server components resolve, which improves Time to First Byte (TTFB) and Largest Contentful Paint (LCP) since the browser can start rendering and hydrating before all data is fetched.
Yes — nested Suspense boundaries let inner components show their own fallback independently of outer ones, so a fast sidebar can render while a slow feed still shows a skeleton. Each boundary resolves and hydrates as soon as its own async work completes.
loading.js creates an implicit Suspense boundary around an entire route segment and applies automatically to all navigations to that route, while explicit
Move the slow fetch inside its own async Server Component, then wrap that component in <Suspense fallback={
This happens when the async operation resolves before React flushes the boundary, but the fallback is still briefly painted. Wrap the component with startTransition on the client side for navigation updates, or use the new React 19 use() hook with a cached promise to avoid unnecessary fallback renders on re-renders.