React use() and Suspense: Streaming Data Without Waterfalls
React use() and Suspense: Streaming Data Without Waterfalls
In the View Transitions article I mentioned in passing that a Suspense reveal can be animated — the skeleton slides out, the real content slides in. That sentence smuggled in an assumption: that the page has a skeleton to swap, which means the data arrives separately from the rest of the page. That's not a given. Plenty of pages still show nothing until everything is ready, and the reason is almost always the same — a data-loading structure that forces requests into a queue.
That queue has a name, a waterfall, and it has an ugly arithmetic property. Request B can't start until A returns, C waits on B, so the total isn't the time of the slowest request — it's the sum of all of them. Four 300ms requests in a chain is 1.2 seconds of blank screen, for data that could have all arrived in 300ms.
Where waterfalls come from
The oldest source is fetching in useEffect. A component renders, then the effect fires the request, and when the data arrives, a child renders and only then fires its request. Every level of the tree adds a full network round-trip. The user watches a cascade of spinners, one per component, each with its own isLoading state you had to write and keep consistent with the others.
The second source is quieter and lives on the server. When you await data at the top of a Server Component, the Next.js docs put it plainly: the request blocks the route from rendering until it completes. Two independent requests awaited one after the other is a waterfall too, just a shorter one that never shows up in the network tab as obviously. And if the slowest request belongs to a recommendations widget at the very bottom of the page, the user stares at nothing while the header — which needed no data at all — waits its turn.
What a suspended component actually does
Before the API, the mechanism, because it explains every pitfall in this article.
Suspense isn't new — React has had it since 2018, for code splitting with React.lazy. The trick it was built on is unusual enough that it was semi-secret for years: a component that isn't ready throws a Promise. Not an error, a Promise. React catches it the way an error boundary catches an error, discards the partially-rendered work, shows the nearest fallback, and subscribes to the thrown Promise. When it resolves, React re-runs the component from the top and keeps whatever it produces this time.
Two consequences fall out of that, and both matter:
Render has to be repeatable. React will throw away a suspended component's work and run it again, possibly several times. A side effect sitting in the render body runs every time.
And the Promise has to be stable between those runs. If re-running the component creates a brand-new Promise, React subscribes to that one, it resolves, React re-runs, a third Promise appears — a component that suspends forever, burning CPU, showing a fallback that never leaves. This is the single most common way to break use(), and it's why the rest of this article cares so much about where the Promise is born.
use() is the blessed API over that old mechanism. It reads a resource during render — most often context, or a Promise — and when the Promise is pending, it suspends the component. No isLoading, no useEffect; the code reads as if the data were simply there.
tsx
"use client";import { use } from "react";export default function Posts({ posts }) {// suspends until the promise resolvesconst allPosts = use(posts);return (<ul>{allPosts.map((post) => (<li key={post.id}>{post.title}</li>))}</ul>);}
One difference from ordinary hooks is genuinely useful: use() isn't bound by the rules of hooks, so you can call it inside a condition or a loop, after an early return. That's allowed precisely because of the throw-and-retry model — there's no hook slot to keep in order.
Suspense, the static shell, and HTML that arrives out of order
<Suspense> marks the boundary a suspended component falls back to. In Next.js this is also how streaming works: React's server renderer emits HTML in chunks aligned to those boundaries. Everything outside them — layout, navigation, the fallbacks themselves — is the static shell, and it goes out immediately. Each boundary is an independent streaming point; content in separate boundaries resolves and arrives without blocking its siblings.
tsx
import { Suspense } from "react";export default function Page() {return (<main><h1>Dashboard</h1> {/* sent immediately */}<Suspense fallback={<StatsSkeleton />}><Stats /></Suspense><Suspense fallback={<FeedSkeleton />}><Feed /></Suspense></main>);}
If <Stats /> takes 200ms and <Feed /> takes 1.5s, the user gets the header instantly, stats at 200ms, feed at 1.5s — instead of a white screen for a second and a half.
Which raises a question worth asking out loud: how does the feed's HTML get into the middle of a document the server already streamed past? It can't be inserted — those bytes are gone. React's answer is a small piece of theatre. The late chunk is appended at the end of the document, out of view, and React streams a tiny inline <script> next to it whose only job is to move that content into the right place and drop the fallback. The Next.js docs note the consequence directly: the content only appears once that script runs.
It's worth knowing because it explains something that otherwise looks like magic — streamed content appears before React has hydrated, because relocating a DOM node needs no framework. And it explains a real constraint: if you're chasing a Largest Contentful Paint measured in hundreds of milliseconds, an element behind a Suspense boundary pays for a round-trip through that script, which an element in the static shell does not.
Start early, read late
Now the pattern that makes this work. In a Client Component that needs data, the temptation is to fetch inside it. The docs recommend the opposite: start the request in a Server Component and don't await it. Pass the bare Promise down as a prop and read it with use().
tsx
import { Suspense } from "react";import Posts from "@/app/ui/posts";import Comments from "@/app/ui/comments";export default function Page() {// both requests start now, in parallel; nobody awaits them hereconst posts = getPosts();const comments = getComments();return (<><Suspense fallback={<div>Loading posts...</div>}><Posts posts={posts} /></Suspense><Suspense fallback={<div>Loading comments...</div>}><Comments comments={comments} /></Suspense></>);}
Look at the timing. Both requests fire during a single render of Page, before anything has waited on anything. Total time is now the slower of the two, not the sum. The waterfall disappears not because some library batched the requests, but because of where the Promise was created — early, high up, and independent of rendering the components that consume it. That's also why the Promise is stable: it's created once on the server, not on every re-run of the component reading it.
Not every waterfall is a bug, though. If the second request genuinely needs a value from the first — playlists for an artist you have to look up by username — the order is real and nothing will parallelize it. What you control is what the user looks at meanwhile. Put the dependent part behind its own boundary and the name appears as soon as the first request lands:
tsx
export default async function Page({ params }) {const { username } = await params;const artist = await getArtist(username);return (<><h1>{artist.name}</h1><Suspense fallback={<div>Loading playlists...</div>}><Playlists artistID={artist.id} /></Suspense></>);}
The chain stays, shortened to its one unavoidable link, and it no longer blocks the part of the page that was ready all along.
The gotcha nobody expects: streaming spends your HTTP status code
Here's the constraint that catches teams in production rather than in development, and it follows from one fact about HTTP: headers go out before the body.
The moment a Suspense fallback renders and the stream opens, the server has already committed to 200 OK and sent every response header. From that point on you cannot change the status code, you cannot add a header, and you cannot redirect at the protocol level. So a notFound() that fires inside a streamed boundary can render the not-found UI, but it can't give you a real 404 — the 200 left the building a second ago.
For pages a crawler or a monitoring system will judge by status code, the rule is simple: do the cheap existence check before anything can suspend.
tsx
export default async function PostPage({ params }) {const { slug } = await params;const exists = await checkSlugExists(slug); // fast, before the stream opensif (!exists) notFound(); // a real 404return (<Suspense fallback={<PostSkeleton />}><PostContent slug={slug} /> {/* the slow part streams */}</Suspense>);}
One fast query awaited up front is a deliberate, tiny waterfall traded for a correct status code. That's the kind of trade worth making on purpose rather than discovering later in a crawl report.
What streaming does to Core Web Vitals
Since the INP article, this blog has had a running theme, and streaming touches all of it:
- TTFB drops to roughly the time it takes to render your layout, instead of the time of the slowest query. The shell leaves early, which also means the
<link>and<script>tags in the first chunk let the browser start fetching CSS, JS and fonts while the server is still working. - LCP can get worse, not better. If your largest element — a hero image, the main heading — sits inside a Suspense boundary, it can't paint until that boundary resolves and its inline script runs. Keep LCP elements in the static shell, outside boundaries.
- CLS is yours to prevent. When a fallback is swapped for content of a different size, everything below it jumps. Give skeletons the dimensions of the thing they stand in for, or reserve the space with a
min-height— or animate the handoff with a View Transition, as in the previous article. - INP improves, via selective hydration. React hydrates boundaries independently as they stream in, and prioritizes hydrating whatever the user just interacted with. Instead of one long hydration task blocking the main thread — exactly the long task the INP article was about — you get several smaller ones, ordered by what the user actually touched.
Pitfalls and best practices
- Never create the Promise during a Client Component's render.
use(fetch(url))inside a Client Component makes a new Promise on every run, and by the throw-and-retry mechanism above, that's an infinite suspension. The Promise must come from somewhere stable: the server, a cache, or a module-level store. - Suspense catches waiting, not failing. A rejected Promise doesn't show the fallback — it propagates as an error. Pair every meaningful boundary with an Error Boundary, or one failed request takes down the tree instead of one card.
- Assume React may use any boundary you declare. The Next.js docs are blunt about it: under a slow network or a busy CPU, concurrent rendering can fall back to a boundary even where you didn't expect it. Adding a boundary means accepting that its fallback can appear.
- Size the boundary like a person would. One boundary around the whole page restores "nothing until everything." A boundary around every line gives you a page that pops in forty pieces. Wrap chunks a user would name as a single thing.
- Bots don't get the stream. Crawlers are served differently — Next.js waits for the data and returns the fully rendered page. Good for SEO, but it means a slow query hits crawler TTFB in a way it never hits a real user's.
- Preview your loading states instead of imagining them. React DevTools can put a boundary into its fallback state on demand, which is the only sane way to check a skeleton you'd otherwise see for 80ms on a fast connection — and the only way you'll notice it's 40px shorter than the content it replaces.
- A Promise prop crosses the network. Its resolved value is serialized into the payload sent to the browser. Don't hand the client a Promise for more data than the client is allowed to see.
Conclusion
The INP article and the scroll-driven animations article shared one instinct: don't make the main thread do work that could happen elsewhere or earlier. Streaming with use() and Suspense applies it to time rather than threads. Instead of a sequence — request, wait, render, request, wait, render — you get parallel streams: everything starts at once, and each part of the page appears the moment its own data lands. The code cost is close to zero; the shift is one of habit. Create Promises early and high, read them late and low, draw the boundaries where a user would draw them, and remember that the moment the stream opens, the status code is already spent.