One question before we start.
When a Suspense boundary shows its fallback and then swaps in the real content, how many network requests happened?
"Two" is the common one, and it's the answer that makes every decision about boundary placement wrong.
One. The response never closed.
That single fact is the difference between treating Suspense as a loading-state API and understanding it as what it actually is: a transport primitive that lets the server send you a tree with pieces missing and fill them in later.
The API is genuinely misleading here. It's called Suspense, it takes a prop called fallback, and everyone puts a spinner in it. All of that points at "loading UI." Almost none of it is about loading UI.
What suspends
Three things, and they're less varied than they look.
An async Server Component that hits an await. The renderer reaches your component, the component asks for data, the data isn't there yet. That component has suspended.
A Client Component reading an unresolved promise with use(). Same shape, different graph.
lazy(), while its chunk downloads. Post 4's dynamic import boundary — the code isn't there yet, so the component can't render yet.
In every case the pattern is identical: React needed something to continue and didn't have it. Suspense is the mechanism for answering the question "so what do we do in the meantime?"
The boundary isn't attached to any of these. It's attached to the tree. React walks up from whatever suspended until it finds the nearest <Suspense>, and that boundary's fallback is what gets shown. Which is why placement is a real decision and not decoration.
What actually goes over the wire
Post 3 established that the server produces a payload — rows of text describing a tree — and that HTML is generated from it. Both of those can be sent incomplete.
When the renderer hits a boundary whose contents have suspended, it doesn't wait. It emits the fallback, writes a placeholder where the real content will go, and carries on rendering the rest of the page. The response stays open.
When the promise resolves, the server writes more rows into that same open response — the real content, and a note about which placeholder it belongs to.
That's it. That's streaming. The response is a stream that stays open, not a document that gets sent.
On a first page load there's an extra step, because the browser has HTML on screen and no React yet. React handles this by shipping the late-arriving markup with a tiny inline script that swaps it into place. It runs before hydration, before your bundle has finished downloading. Which is why streamed content appears on slow connections even when the page isn't interactive yet.
Worth watching this happen once with the Network tab open on a throttled connection.
On a navigation there's no HTML at all — post 3's asymmetry — so the rows just arrive on the open response and React reconciles them in.
No second request. Not on first load, not on navigation. Every mental model that assumes otherwise leads to putting too few boundaries in, out of a fear of round trips that don't exist.
The same boundary, two transports
Here's the part that isn't in any tutorial.
A <Suspense> boundary is one piece of JSX. But what happens when it suspends depends entirely on how the user got to the page — and post 3 already told you why. A document request produces HTML and a payload. A navigation produces a payload only.
So the same boundary streams two completely different ways.
On a first load
There's HTML on screen and no React yet. The browser has painted your fallback and has no idea anything else is coming.
So when the content resolves, the server can't just send a payload row — nothing is listening for one. It sends markup, parked in a hidden element at the end of the document, along with a tiny inline script that moves it into the right place and removes the fallback.
That script isn't your bundle. It's a few hundred bytes React inlines into the document, and it runs the moment it arrives.
Which means the swap happens before hydration. Before your JavaScript has finished downloading, before React has started, before anything is interactive. On a slow connection you can watch skeletons fill in on a page where clicking still does nothing.
Separate "content arrived" from "page became interactive" with your own eyes.
On a navigation
No document, no HTML, no inline scripts. React is already running and already holds the tree.
The boundary suspends, the fallback renders from the payload, and when the content resolves the server writes more rows onto the same open response. React reconciles them in — the same reconciliation from post 2, applied to rows arriving over time.
Same JSX. Same boundary. One is a DOM-patching script that runs before React exists; the other is a tree diff inside a React that's been running for ten minutes.
That's the thing to hold onto about Suspense. It's not a component that shows a spinner. It's a declaration that a subtree is allowed to arrive late — and the machinery for "arriving late" is completely different depending on whether React is awake yet.
Two mechanics worth knowing
loading.tsx is a Suspense boundary at segment granularity. Next wraps the segment in one for you. It's why most App Router apps have streaming without anyone having decided to use it — and why the granularity is usually coarser than you'd have chosen.
Siblings render concurrently; parent-to-child doesn't. Two async Server Components beside each other fetch in parallel with no effort from you. But a parent that awaits before rendering a child that also awaits is strictly sequential, because the child cannot start until the parent returns. That's a property of the renderer, not of your boundaries — and no amount of <Suspense> will change it.
Boundaries are also hydration units
A second job, less obvious than the first.
Post 3 covered hydration — React executing client components in the browser and attaching handlers to existing DOM. That doesn't happen as one atomic pass. React hydrates boundary by boundary.
Which means it can prioritise. If a user clicks something inside a boundary that hasn't hydrated yet, React hydrates that boundary first, ahead of whatever it was going to do next, and then replays the click.
So a boundary does three things at once: it decides what streams separately, what shows a fallback, and what hydrates as a unit. Placement affects all three.
What streaming costs you
Once the first byte is out, headers are gone. That's not React's rule, it's HTTP's, and it has consequences people hit in production.
You cannot change the status code. If your page starts streaming and then a component inside a boundary calls notFound(), the response is already a 200. The framework handles the UI — the error surfaces client-side and the right thing renders — but the status code that went out is the status code you're stuck with. Crawlers and monitoring see a 200.
Redirects get messy for the same reason. A redirect after the shell has flushed can't be an HTTP redirect. It becomes a client-side navigation.
The practical shape: anything that needs to affect the response rather than the page — auth checks, 404s, redirects — should happen before you start streaming. Above the boundary, not inside it.
The sibling primitive: error boundaries
Suspense answers "what if this isn't ready yet." There's an obvious second question — what if this fails? — and it has a nearly identical answer.
app/dashboard/
error.tsx ← error boundary
loading.tsx ← suspense boundary
page.tsxSame shape, same placement logic, same trade-off. Put the boundary too high and one failed widget takes down the page. Put it around the widget and the rest survives.
The two pair up naturally, because they're answers to the two ways an async subtree can fail to produce content:
Suspense bounds latency — it isn't here yet
Error boundary bounds failure — it isn't coming
And the streaming caveat applies to both. If a component throws after the shell has flushed, the status code has already gone out as a 200. The error boundary still renders, client-side, from the stream. But the response was never a 500.
What's next
Everything so far has been the read path. A request arrives, the server produces a tree, the browser renders it, and later fills in the parts that weren't ready.
Nothing has gone the other direction. No form has been submitted, nothing has been written, and the browser has had nothing to say to the server except "give me the next route."
If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.