Skip to content
Back to Blog
ReactNext.jsStreaming SSRSuspenseServer-Sent EventsLoading States

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.

12 min read

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.

  1. 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.
  2. Skeleton. Communicates layout. The user learns what's coming before it arrives, so the arrival isn't a jump-scare.
  3. 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.
  4. 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 durationWhat to showWhy
Under 1sNothingAn indicator that flashes is worse than no indicator
1–2sSpinnerEnough to confirm the click registered
2–10sSkeletonLayout preview beats undifferentiated spinning
Over 10sProgress or statusSkeletons 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

SituationRungWhat to show
Sub-second fetchNoneNothing
Button action, 1–2sSpinnerInline spinner on the button
Page load, known layout, 2–10sSkeletonFallback matching final dimensions
Slow region on an otherwise fast pageStreamed HTMLSuspense with the await inside
Multi-step job, known totalStreamed status"1,240 of 4,000 rows"
Multi-step job, unknown totalStreamed statusNamed steps, no percentage
Re-fetching data already on screenNoneDim the stale content, keep it
User-initiated mutationNoneuseOptimistic, render the outcome now
Job finished with mixed resultsReceipt"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.