React Streaming: The Best Loading State for a Long Task Isn't a Loader
You'll learn why skeletons stop working past ten seconds, how to place Suspense boundaries so streaming actually streams, when to switch from streaming shape to streaming status over SSE, and the one rule that decides whether you're allowed to show a percentage.
TL;DR: A skeleton screen is a guess your client makes about layout while the server sits there knowing exactly what's happening. Streaming closed that gap: once the connection stays open, the server can narrate the work instead of leaving the browser to draw grey boxes at it. Use skeletons for waits under ten seconds where you know the final shape, switch to streamed status for anything longer, and only ever show a percentage when you have an honest denominator.
We shipped a CSV importer that took about forty seconds on a big file. It showed a spinner the whole time. Support started getting tickets about duplicate rows, and it took us embarrassingly long to work out why: people assumed the page had hung, hit refresh, and fired the import again.
The spinner was technically correct. Something was loading. It just never said which of the six steps it was on, how many of the 4,000 rows had landed, or whether the thing was still alive. Forty seconds of a component telling the user "trust me."
The fix wasn't a faster importer. It was the realization that the server knew all of that the entire time, and we had an open HTTP connection to say it on, and we were using that connection to send nothing.
The Ladder Most Apps Stop Climbing Halfway Up
There are four rungs to loading UI, and most codebases live on rung two forever.
- Spinner. Communicates activity. Communicates nothing else. Every second looks identical to the last one, which is exactly why waits behind a spinner feel longer than they are.
- Skeleton. Communicates layout. The user learns what's coming before it arrives, so the arrival isn't a jump-scare.
- Streamed HTML. The shell arrives first and real content fills in per region as the server resolves it. Not a placeholder for content: actual content, early.
- Streamed status. The server narrates the work itself. "Validating 4,000 rows." "Step 4 of 6."
Rungs one and two have something in common that's easy to miss: they're both guesses, and they're both made on the wrong machine. The client has no idea how far along anything is. It draws a shape and hopes. Meanwhile the server has the row count, the current step, and the error tally, and it's saying none of it.
Nielsen Norman Group put actual numbers on when each rung stops working:
| Wait duration | What to show | Why |
|---|---|---|
| Under 1s | Nothing | An indicator that flashes is worse than no indicator |
| 1–2s | Spinner | Enough to confirm the click registered |
| 2–10s | Skeleton | Layout preview beats undifferentiated spinning |
| Over 10s | Progress or status | Skeletons stop conveying state and start looking broken |
That last row is the one people ignore. A skeleton at second thirty is doing the same job a spinner does: shimmering. Our importer had a beautifully crafted skeleton table. At second thirty-five it was indistinguishable from a crash.
Rung Three: Put the Boundary Where the Slow Thing Is
Streaming SSR is how you stop guessing about shape. React sends the shell immediately, then flushes each Suspense boundary's real content as its data resolves. Shopify built Hydrogen on exactly this and wrote up why: the browser gets the HTML shell without blocking on the server-side data fetch, and each region hydrates as it lands.
Here's the version that blocks:
// ❌ The entire page waits for the slowest query
export default async function OrderPage({ params }: Props) {
const order = await getOrder(params.id); // 40ms
const recommendations = await getRecommendations(params.id); // 1,800ms
return (
<>
<OrderSummary order={order} />
<Recommendations items={recommendations} />
</>
);
}The order summary is ready in 40ms and sits in memory for another 1.8 seconds waiting for a recommendations widget nobody scrolled to.
// ✅ Summary flushes immediately, recommendations stream in behind a boundary
export default async function OrderPage({ params }: Props) {
const order = await getOrder(params.id);
return (
<>
<OrderSummary order={order} />
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations orderId={params.id} />
</Suspense>
</>
);
}
async function Recommendations({ orderId }: { orderId: string }) {
const items = await getRecommendations(orderId); // await moved inside
return <RecommendationList items={items} />;
}The thing to notice is the second block: getRecommendations moved into the suspended component. That's the whole trick, and it's the single most common way streaming silently doesn't work.
// ❌ Suspense here does nothing
const recommendations = await getRecommendations(params.id);
return (
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations items={recommendations} />
</Suspense>
);That await is at page level, so the page function hasn't returned yet. There's no tree to stream, no fallback to show, nothing for the boundary to do. The fallback is unreachable code. The page still blocks for 1.8 seconds and the Suspense wrapper is decoration. If you've ever added boundaries and seen your TTFB not move at all, check where the await lives before you check anything else.
The Layout Contract, or Why Streaming Can Make Things Worse
Here's the honest caveat: streaming without a layout contract is worse than blocking. You trade one clean wait for a sequence of things shoving each other down the page.
// ❌ Fallback and content have nothing in common
<Suspense fallback={<p>Loading recommendations...</p>}>
<Recommendations orderId={id} /> {/* renders a 320px grid */}
</Suspense>A one-line <p> gets replaced by a 320px grid. Everything below it jumps. Do that at three boundaries and the page rearranges itself three times while the user is trying to read it. You made TTFB better and the experience worse, and your Core Web Vitals will tell on you.
// ✅ The fallback reserves the real space
function RecommendationsSkeleton() {
return (
<div className="grid h-80 grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-lg bg-neutral-200" />
))}
</div>
);
}Same height, same grid, same gap. The content swaps in place instead of pushing.
The rule I'd write on the wall: a skeleton is a layout contract, not a decoration. If you can't describe the final dimensions, you're not ready to write the fallback, and a skeleton that lies about size is worse than a spinner that doesn't claim to know anything.
One more thing while we're being honest about skeletons: NN/g also flags that shimmer animations can be distracting and cause accessibility problems. Respect prefers-reduced-motion, and if the shimmer is the only reason anyone believes the page is alive, the shimmer is not the problem you should be solving.
Rung Four: When the Wait Is Work, Stream the Status
Suspense streaming solves fetching. It doesn't solve working. Our CSV import wasn't waiting on a query, it was doing forty seconds of validation, insertion, and reconciliation. There's no shape to preview because the result doesn't exist yet. The server isn't fetching a thing, it's building one, and it knows exactly how far along it is.
So send that. Server-Sent Events over a route handler, which is roughly fifteen lines:
// app/api/import/route.ts
export async function POST(req: Request) {
const file = await req.formData();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: object) =>
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
send({ step: "Validating", processed: 0, total: 4000 });
for await (const batch of importRows(file)) {
// emit the real counter, not a fabricated one
send({ step: "Importing", processed: batch.done, total: batch.total });
}
send({ step: "Done", processed: 4000, total: 4000, failed: 3 });
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream" },
});
}Note processed and total come out of the import loop itself. We're not interpolating a fake progress bar on a timer, which I have absolutely shipped before and do not recommend.
On the client:
function useImportProgress() {
const [status, setStatus] = useState<ImportStatus | null>(null);
const start = useCallback(async (file: File) => {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/import", { method: "POST", body });
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
for (const line of value.split("\n\n")) {
if (line.startsWith("data: ")) setStatus(JSON.parse(line.slice(6)));
}
}
}, []);
return { status, start };
}Yes, that's a manual reader instead of EventSource. EventSource is GET-only and we're uploading a file, so a POST plus a stream reader is the price. You also lose the browser's automatic reconnect, which matters for long jobs. That's the honest tradeoff of this approach, and the fix is a resumable job ID rather than pretending the connection won't drop.
Now the UI can say something true:
{status && (
<div role="status" aria-live="polite">
<p>{status.step}</p>
<p>{status.processed.toLocaleString()} of {status.total.toLocaleString()} rows</p>
</div>
)}Nobody refreshed after that shipped.
The Denominator Rule
The most useful thing I took from LogRocket's writeup on async workflow patterns is a rule that kills a whole category of bad progress bars:
Show a percentage only when you have an honest denominator.
If you know it's 4,000 rows, say "1,240 of 4,000." If you don't know the total, do not invent one. Everyone has seen the bar that hits 90% in two seconds and then sits there for a minute, and everyone has correctly concluded that the bar is lying. Once a user catches a progress indicator lying, they stop trusting every indicator in your product.
When you have no denominator, show named steps instead:
// ❌ There is no real total here, so this number is fiction
<ProgressBar percent={fakePercent} />
// ✅ Steps are true even when totals aren't knowable
const STEPS = ["Connecting", "Validating", "Importing", "Reconciling"];
<StepIndicator steps={STEPS} current={status.step} />Named steps still convey forward motion, and they have the useful property of being accurate.
While you're at it, design the finish state too. Long jobs rarely end in clean success or clean failure, and collapsing a mixed result into a green checkmark is how silent data loss happens. Show the itemized receipt:
<p>3,997 imported, 3 failed, 0 skipped</p>
<button onClick={retryFailed}>Retry 3 failed rows</button>That's a real outcome the user can act on. "Import complete" is not.
When Not to Do Any of This
Streaming is not the answer to every wait, and reaching for it reflexively is its own mistake.
Under a second, show nothing. An indicator that appears and vanishes in 300ms reads as a flicker, not as feedback.
For mutations, optimistic UI beats streaming. If the user clicks "like," don't stream them the progress of their like. Render the outcome immediately with useOptimistic and roll back if the server disagrees. Streaming narrates a wait. Optimistic UI deletes it.
Stale content beats a skeleton. This one I've argued before in the post on concurrent rendering: when you already have results on screen and you're fetching new ones, do not rip them out and drop in a skeleton. Dim them slightly and leave them there. Showing slightly outdated data beats showing an empty rectangle, and an empty rectangle is what a skeleton is.
And none of this replaces being fast. A skeleton is not a performance fix, a stream is not a performance fix, and a very informative forty-second wait is still a forty-second wait. Fix the query first. Narrate what's left over.
Quick Reference
| Situation | Rung | What to show |
|---|---|---|
| Sub-second fetch | None | Nothing |
| Button action, 1–2s | Spinner | Inline spinner on the button |
| Page load, known layout, 2–10s | Skeleton | Fallback matching final dimensions |
| Slow region on an otherwise fast page | Streamed HTML | Suspense with the await inside |
| Multi-step job, known total | Streamed status | "1,240 of 4,000 rows" |
| Multi-step job, unknown total | Streamed status | Named steps, no percentage |
| Re-fetching data already on screen | None | Dim the stale content, keep it |
| User-initiated mutation | None | useOptimistic, render the outcome now |
| Job finished with mixed results | Receipt | "3,997 imported, 3 failed" plus retry |
What Changed in How You Should Think About This
The framing that fixed this for me: a loading state is not a placeholder, it's a message. The question stopped being "what do I render while I wait" and became "what does the server know right now that the user doesn't."
Once you ask it that way, the rungs fall out on their own. A spinner is a message with no content. A skeleton is a message about shape, which is genuinely useful right up until the wait gets long enough that shape stops being the interesting question. Streamed HTML is the content itself, early. Streamed status is the server telling you what it's doing, which is the only thing that actually helps when the wait is work rather than a fetch.
That importer sat behind a spinner for forty seconds while the server counted rows in silence. The connection was open the entire time. We just never said anything on it.