Teams adopting streaming usually treat <Suspense> as a switch: wrap something, get progressive rendering. Then the page still feels slow, and the response is to add more boundaries. We read a lot of App Router codebases where every third component has one, the screen assembles in seven visible stages, and the measured LCP is worse than the non-streaming version it replaced.
Boundary placement is an architecture decision with measurable consequences. It decides what the user sees first, what shifts after paint, and — because a boundary is also where rendering stops and waits — where request waterfalls form. Very little about it is a matter of taste, which is good news: you can measure it.
What a boundary actually does
Two things, and it helps to keep them separate.
On the server, during a streaming render, a Suspense boundary is a flush point. Everything above it can be sent immediately; the subtree inside it is replaced by its fallback in the initial HTML, and the real content arrives later in the same response, out of order, with a small inline script that swaps it into place. That is the whole mechanism. Content outside any boundary blocks the first byte of HTML.
On the client, the boundary is the place a suspending read is caught — a use() on an unresolved promise, a React.lazy chunk still downloading, a cache library configured to suspend. Same fallback, different trigger.
Two consequences follow that explain most of the mistakes:
- A boundary that wraps the entire page delays nothing and reveals nothing. The shell is a spinner, the HTML flush happens instantly, and the user waits exactly as long as before, now with less information. In the App Router this is what a top-level
loading.tsxon a route group often amounts to. - Nothing below a boundary starts earlier because of it. Boundaries control revelation, not scheduling. If the slow fetch is in a component that only renders after its parent's data resolves, the parent's boundary does not help; you have a waterfall and no amount of fallback UI will fix it.
Start with the slow segment, not the tree
Before moving a boundary, find out which part of the response is slow and by how much. The tools are unremarkable and sufficient.
Open the document request in DevTools and read the timing: TTFB tells you how long the un-suspended part of the tree took to render on the server. If TTFB is 900ms, you have server work outside your boundaries, and no boundary rearrangement will change it — move the work inside a boundary or off the critical path.
Then look at the response body arriving in chunks (the Network panel shows the streamed document growing; curl -N against the route shows the same thing in text, which is often easier to reason about). You are looking for the gap between the first chunk and the chunk containing the content the user actually came for.
Instrument the server side too. In Next.js, wrapping suspicious data functions in performance.mark / measure and logging the durations per request tells you which segment owns the delay. A trace exporter is better if you already run one. The point is to have a number per segment before you decide which segments get their own boundary — the slow one usually is not the one people assume, and product-critical content is frequently the fast one being held back by a sidebar that calls a recommendations service.
The placement rule we use
Put a boundary around a segment that is both slow and independently useful. Both halves matter.
Slow: it contributes meaningfully to the wait. Wrapping a 15ms query in a boundary adds a fallback, a flush, and a DOM swap to save nothing.
Independently useful: the rest of the page makes sense without it, and the user can start reading or interacting while it loads. A comments list under an article qualifies. Half of a pricing table does not.
In practice this yields few boundaries, not many: usually the primary content region, and one per genuinely slow secondary region — a feed, a chart, a personalised block, an activity list. A page with more than about five is usually describing its component tree rather than its loading behaviour.
Two placements that are almost always wrong:
- Around the element that owns your LCP. The fallback paints first, the real content paints later, and LCP is measured on the later paint — with the added risk that the swap counts as a layout shift. If a hero image or headline is your largest contentful element, it belongs in the blocking shell. Keep the slow personalisation next to it, in its own boundary, and keep the headline static.
- Deep inside a list, per row. Twenty row-level boundaries produce twenty flushes and a page that twitches for two seconds. Wrap the list.
Fallbacks are a CLS decision
A fallback that does not reserve the same space as its content is a queued layout shift. Streaming makes this worse than client-side loading states, because the swap happens during initial page load, inside the CLS measurement window, after the user has begun reading.
So: give skeletons explicit dimensions taken from the real component, not approximate ones. Prefer a fixed-height container for a variable-height region and accept the internal scroll or clipping. Then verify — the Performance panel's Layout Shift records name the shifting node, and the Web Vitals attribution build (onCLS with attribution) gives you the same in field data. If a skeleton is difficult to size honestly, that is an argument for not streaming that region at all; blocking on 120ms of server work is often the better trade than shifting the page.
One detail worth knowing: React throttles the reveal of nested boundaries slightly to avoid a cascade of separate paints. It smooths a bad layout but does not fix one. Design the sizes properly and the throttling stops mattering.
Boundaries do not cure waterfalls
The most common performance finding we write up on streaming pages is not boundary placement at all. It is sequential awaits.
// Server Component — two round trips, one after the other
const user = await getUser(id);
const orders = await getOrders(user.accountId);
If getOrders genuinely depends on the user record, that dependency is real and the fix is a better query, not a boundary. If it does not — and often it does not, because the id was available the whole time — start both before awaiting either:
const userPromise = getUser(id);
const ordersPromise = getOrders(accountId);
const [user, orders] = await Promise.all([userPromise, ordersPromise]);
The other pattern worth having in the toolbox: start the request in the parent, pass the unresolved promise down, and read it with use() inside a child that sits in its own boundary. The fetch begins at the top of the render; only the display waits. It reads oddly the first time and it is the correct shape when one region's data is slow and the rest of the page does not need it.
A related trap on the client: a React.lazy component whose chunk only begins downloading when the boundary hits. If the component is reliably needed, preload the chunk rather than paying for a request that starts at render time.
Verify with the same numbers you started with
Re-measure the segment timings and re-check field data before keeping the change. Lab numbers tell you the mechanism worked; only field data — CrUX, or your own RUM — tells you it mattered on real connections and real devices. We have moved boundaries that improved a Lighthouse trace and made no difference at the 75th percentile, which means the fix was aimed at the wrong thing and should be reverted rather than defended.
Also check the error path while you are here, because boundaries and error boundaries interact. A suspended segment that throws after the shell has flushed cannot be recovered by a redirect — the status code is already sent. Pair each meaningful Suspense boundary with an error boundary that degrades that region without taking down the page, and test it by making the underlying call fail on purpose.
If your page has one slow region
That is the normal case, and the answer is one boundary. Put it around that region, size its fallback from the real component, make sure the region's data request starts at the top of the render rather than after a parent's await, and leave the rest of the tree blocking. Then measure again.
If the numbers do not move, the problem is not revelation order, and streaming was the wrong fix. That is worth knowing early — it is usually a query, a cold serverless path, or a third-party script, and none of those are solved in the component tree.