Route Loading Boundaries
Where the spinner belongs: the subtree a fallback is allowed to replace, why nesting them matters, and why one boundary at the top blanks the whole page on every navigation.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Where should the loading state live, and why does my entire page go blank every time someone clicks a link?
Someone clicks the next item in a list. They expect the item to appear. They do not expect the navigation, the sidebar and the list they were reading to vanish and come back.
Wrap the application in one boundary with a full-page spinner. Every route gets a loading state, there is one place to maintain, and nothing can ever render without its data.
Every navigation blanks the entire window — nav, header, sidebar, the list the user was reading — and then rebuilds it. It is visually indistinguishable from a full page reload, except slower, because the reload at least kept the old page on screen until the new one was ready.
- Every navigation blanks the entire window — nav, header, sidebar, the list the user was reading — and then rebuilds it. It is visually indistinguishable from a full page reload, except slower, because the reload at least kept the old page on screen until the new one was ready.
- The scroll position is gone, because the scroller was unmounted. So is focus, because the focused element was inside the discarded subtree (Scroll Restoration).
- A navigation that would have resolved almost instantly still flashes a spinner: the fallback appears and disappears within a frame or two, which reads as a flicker rather than as progress.
- The layout shifts twice per navigation — once when everything collapses to a centred spinner, once when the real content comes back at a different height (Visual Stability).
- Each nested view fetches its own data when it mounts, so the boundaries resolve in sequence and the page arrives one level at a time — a waterfall built out of component structure (Reading a Network Waterfall).
- A screen-reader user hears nothing at all. The old content was removed, the new content has not arrived, and no live region said either thing (Live Regions and Announcement).
What is actually happening
In the browser, not in the framework.
- A loading boundary is a point in the tree that declares: while anything beneath me is pending, render this fallback instead of my children. The boundary's position is therefore a statement about how much of the page is allowed to disappear.
- Boundaries nest, and the nearest enclosing one wins. That is the entire mechanism, and it is why placement is the only real decision: a boundary at the root can only ever produce a whole-page fallback.
- Boundaries compose with the route tree. A nested match chain gives you natural boundary positions — one per layout level — so the shell can stay while the outlet swaps (Route Matching).
- There are two fundamentally different navigation behaviours available, and they are not variations of each other. Replace: unmount the old view, render the fallback, render the new view. Retain: keep the old view on screen, mark it pending, swap when the new one is ready. A cross-document navigation does the second, which is why the browser never blanks a page for you.
- Sibling boundaries are independent. Two slow regions on one page can resolve separately, and the user gets whichever arrives first, which is the whole argument for streaming a page in parts (Streaming Server Rendering).
- A loading boundary is not an error boundary. Pending and failed are different states, and a route needs an answer for both plus a way to retry (Loading, Error, Empty — The States You Did Not Render).
- Fallbacks are rendered by your code, so they are also a layout decision: a fallback of a different size than the content it stands in for guarantees a shift when the content arrives (Intrinsic Sizing and the Automatic Minimum).
What this makes the browser do
And which of it is avoidable.
- A root-level boundary makes every navigation a full-page teardown and rebuild: style for every element, layout for the entire document, paint for the whole viewport. It is the most expensive possible way to change one panel (The Cost of a Change).
- A boundary at the outlet keeps the shell's DOM, its computed styles and its layout boxes alive. Style recalculation and layout are limited to the swapped subtree, and CSS containment can bound the dirty region further (CSS Containment).
- A retained old view costs memory — two view trees exist briefly — and saves the browser from re-laying-out anything that is not changing.
- Skeletons are cheap to render and easy to make expensive: a hundred shimmering placeholders each animating a background gradient is a hundred paint invalidations per frame (Cheap and Expensive Animation).
- Avoidable work entirely: a fallback that appears and is replaced within a frame or two. Delaying the fallback slightly means fast navigations do no fallback work at all.
A boundary is a statement about what may disappear
Draw the route tree and mark where the boundaries sit, and the behaviour of every navigation is readable off the picture. Whatever is inside the nearest boundary to the pending data is what the user loses while they wait. Put it at the root and they lose the page; put it at the outlet and they lose the panel.
The diagram below shows both placements on the same tree. Note that the difference is not a different amount of loading — the same request takes the same time either way. It is a difference in how much surrounding context is destroyed and rebuilt while that request is in flight, and how much layout the browser has to redo when it lands.
- The shell — navigation, header, any persistent panel — belongs outside every route boundary, always.
- Sibling boundaries are independent, which is what lets a slow activity feed stop holding up an order that is already available (Streaming Server Rendering).
- A boundary is a rendering statement, not a fetching one. Where the request starts is a separate decision, and it is the one that creates waterfalls (The Life of a Fetch).
- Pair each boundary with an error state and a retry, or the pending state becomes the failure state (Network Failures Only the Client Can See).
How much of the page is allowed to go away
There are four answers, and the right one depends on how long the wait is likely to be and how much context the user needs to keep. The mistake is not picking the wrong one; it is picking one for the whole application and applying it to every navigation regardless.
The ordering below is roughly by increasing wait. Very short waits should show nothing at all — feedback that appears and disappears is noise. Long waits need something with structure, because a spinner conveys "waiting" and a skeleton conveys "waiting for a list of things that will look like this".
How long is the wait likely to be, and how much of the surrounding context does the user need?
when Cached or near-instant. The data is already in the client cache and the navigation is effectively synchronous (Stale-While-Revalidate).
cost If the assumption is wrong, the interface appears frozen: the click produced no visible response at all.
when A short wait between sibling routes, where context matters — a list-and-detail layout, a filter, a paginated table.
cost The screen no longer matches the URL for a moment. Without a visible pending indicator this is indistinguishable from a click that did not register.
when A longer wait, or a route whose shape is known and stable enough to draw in advance.
cost A second layout to build and maintain alongside the real one, and a shift on arrival if the dimensions do not match (Visual Stability).
when A genuine full-page transition: initial load, an authentication boundary, or a route that legitimately replaces the entire shell.
cost Everything below it is destroyed — scroll, focus, component state — and rebuilt. Correct occasionally, catastrophic as a default (Scroll Restoration).
What each choice costs the browser
Boundary placement is one of the few architectural decisions in a frontend with a direct, mechanical cost in the rendering pipeline. A root fallback discards every layout box on the page and recomputes all of them; an outlet fallback touches one subtree. The user feels the difference as a page that flickers versus a panel that swaps.
The maybe entries below are honest rather than evasive. Whether the swap of a subtree forces layout on its ancestors depends on the surrounding layout: a fixed-size container contains the damage, a grid or flex parent that sizes to its content does not, and CSS containment can turn several of these maybes into a no (CSS Containment).
| Change | style | layout | paint | composite | Why |
|---|---|---|---|---|---|
| Root boundary: whole page becomes a centred spinner | yes | yes | yes | yes | Every element is unmounted and re-created. Style is recomputed for the entire document, layout runs over all of it, and the whole viewport repaints — twice per navigation, since the content comes back the same way. |
| Outlet boundary: one pane becomes a skeleton of the same size | yes | maybe | yes | maybe | Style for the new nodes only. Layout is avoidable if the skeleton reserves the same box and the container does not size to its content; paint is limited to the pane's area. |
| Retain the old view, add an inline determinate progress bar | yes | no | yes | maybe | Nothing unmounts, so no geometry changes. If the bar is animated with transform on its own layer, the per-frame update is compositor work rather than paint (Cheap and Expensive Animation). |
| Fade the retained view to reduced opacity while pending | yes | no | no | yes | Opacity on an already-promoted layer is a compositor-only change: no geometry, no repaint of the contents. The cheapest honest pending signal available. |
| Skeleton replaced by content of a different height | yes | yes | yes | yes | The shift the user actually feels. Everything after the boundary in flow moves, which is a layout pass over the rest of the document and the reason skeleton dimensions matter (Visual Stability). |
caveat Every maybe here is a statement about the surrounding page rather than about the change. Containment, fixed dimensions and layer promotion can each turn a maybe into a no; a content-sized ancestor or a shared scroller turns it into a yes. Confirm with the Performance panel on the actual page rather than reasoning from the row alone.
<Boundary fallback={<FullPageSpinner />}>
<AppShell>
<Router />
</AppShell>
</Boundary>
// click /orders/9
// -> nav, header, sidebar and list all unmount
// -> centred spinner, one frame or thirty
// -> everything remounts
// lost: scroll position, focus, list virtualisation state,
// any open menu, and the user's sense of place<AppShell> {/* never unmounts */}
<OrdersLayout> {/* stays across sibling routes */}
<Boundary
fallback={<DetailSkeleton />} {/* same dimensions as content */}
delayMs={SHORT} {/* no flicker on fast navigations */}
onPending={() => setLinkBusy(true)}
>
<OrderDetail id={id} />
</Boundary>
</OrdersLayout>
</AppShell>
// click /orders/9 -> the list stays, one pane skeletons,
// scroll and focus survive, layout does not shiftThe request takes exactly as long in both versions; nothing about the network changed. What changed is how much of the page the browser has to destroy and rebuild while waiting, and how much context the user loses — scroll, focus, virtualisation state and the list they were reading. The second version also makes the pending state visible on the control that started it, so the user does not click twice.
How to build it
Most important first.
- Put the boundary at the smallest subtree that actually depends on the pending data. If the sidebar does not need the order, the sidebar must not be inside the boundary that waits for it.
- Keep the application shell outside every route boundary. Navigation, header and any persistent panel should never be capable of disappearing during a navigation.
- Prefer retain-and-mark-pending over replace-with-fallback for navigations between sibling routes. Keeping the old content visible with a progress indicator is what the browser does, and it is what users read as "working" rather than "broken".
- Delay the fallback. Showing nothing for a short beat and then a skeleton removes the flicker on fast navigations without making slow ones feel slower (Interaction Responsiveness).
- Make the fallback the same shape as the content. A skeleton that reserves the real dimensions turns a layout shift into a fade and makes scroll restoration tractable (Visual Stability).
- Hoist data requirements to the route rather than fetching on mount inside each level, so nested boundaries do not serialise into a waterfall. Declare what the route needs; fetch it in parallel (The Life of a Fetch).
- Pair every loading boundary with an error boundary and a retry, and decide which one owns the failure. A route with a pending state and no failure state fails by showing the pending state forever.
- Give the pending state to the control that started it as well: a link that is loading should look like it, so the user does not click it again (Optimistic UI).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Removing content and replacing it with a spinner announces nothing. Assistive technology reports what changed only if something tells it to, so a boundary transition needs a polite live region: "Loading orders", then "Orders loaded, 24 results" (Live Regions and Announcement).
- Mark the pending region with
aria-busy="true"while it is loading and remove the attribute when it resolves, so a screen reader can describe the region's state rather than reading a half-built tree. - Do not announce every fast navigation. Gate the loading announcement behind the same delay as the visible fallback, or the user gets a stream of "loading, loaded, loading, loaded" that drowns out the destination.
- Focus must survive the transition. If focus was inside the subtree the boundary replaced, it is now on
bodyand the tab order has reset; move focus deliberately to the new content when it arrives, not to the fallback (Focus Management). - A skeleton must not be readable as content. Hide decorative placeholders from the accessibility tree with
aria-hidden, and let the live region carry the meaning instead (The Rules of ARIA). - Never trap the user in a fallback with no exit. A failed load needs a heading, a message and a focusable retry control — not a spinner that never stops (Errors People Can Actually Perceive).
What can go wrong
- The boundary is too high, so the whole page blanks. The most common placement mistake, and the one that makes an application feel worse than the multi-page version of itself.
- The boundary is too low, so a dozen independent skeletons appear across one screen and the page assembles visibly in pieces.
- Fallback flicker on fast navigations — a spinner that appears and vanishes, which reads as a glitch rather than as feedback.
- A pending state with no timeout and no error path, so a failed request leaves a skeleton on screen indefinitely and the user has nothing to act on (Network Failures Only the Client Can See).
- Nested boundaries that each trigger their own fetch on mount, producing a request waterfall whose depth equals the depth of your component tree.
- The mitigation fails too: retaining the old view without any visible pending indication means a click appears to have done nothing, and the user clicks again — which is worse than a spinner (Loading, Error, Empty — The States You Did Not Render).
- A skeleton whose dimensions do not match the content, converting every load into a layout shift the moment data arrives.
- Two navigations in flight with a retained view: the first response can arrive after the second, rendering data for a route the user has already left (Out-of-Order Responses).
- The fallback delay timer and the data. If the data arrives just after the timer fires, the user gets a single frame of skeleton — the flicker the delay was meant to prevent (The Rendering Opportunity).
- Sibling boundaries resolving in an order that depends on the network rather than on the layout, so the page assembles differently on every visit.
- A retry racing the original request, where both are in flight and the older one wins (Five Components, One Request).
- A loading boundary is a rendering concern, not an authorization one. Rendering a route's shell while its data request is still pending must not reveal anything the user is not entitled to — labels, counts and headings included (Authorization-Aware UI).
- A failed request must not render its error verbatim. Server messages can carry internal identifiers, paths or query fragments; render a mapped message and log the detail (The Error Model: Structure Over Apology in API Design).
- A pending state that retries on its own is a client-side amplifier. Bound retries, back off, and never retry a non-idempotent request automatically (Retries, and the Duplicate Order).
- Retaining a stale view during a navigation means data for the previous route stays on screen after a session has expired. Revalidate rather than assuming the retained view is still authorised (Session Expiry and the Refresh Race).
- "One boundary is simpler." One boundary is a decision that the whole page may vanish at any time. That is not simplicity, it is a coarse answer applied everywhere.
- "A spinner means the app is responsive." A spinner means work is pending. Retaining the previous content with a progress indicator conveys the same thing and keeps the user's context (Loading, Error, Empty — The States You Did Not Render).
- "Skeletons are a design detail." A skeleton is a layout contract: it is what makes the arrival of content a fade instead of a shift (Visual Stability).
- "Nested boundaries cause waterfalls." Nested *fetching* causes waterfalls. Boundaries are about what renders; where the request is initiated is a separate decision, and the fix is to hoist the request, not to flatten the UI (The Life of a Fetch).
- "The loading state is the last thing to build." It is the state most users see most often on a slow connection, and its placement determines the cost of every navigation in the application.
Measuring it, and what changes in the field
- The Performance panel shows the cost directly: a root-level boundary produces a layout pass over the whole document on every navigation, and it is unmistakable next to a scoped one (Layout, Paint and the Main Thread).
- The Network panel shows whether nested boundaries have serialised your requests. A staircase of dependent requests is a boundary and data-fetching structure problem, not a bandwidth one (Reading a Network Waterfall).
- Visual stability in the field is the metric that catches mis-sized skeletons, because the shift happens on data arrival and never on your machine (Vitals in the Field).
- Interaction latency tells you whether the transition feels immediate to real users; the fallback delay is a parameter you should be tuning against field data rather than taste (Interaction Responsiveness).
- For the accessibility half, there is no panel again: navigate with a screen reader and confirm that a slow route says something, once.
- On a fast network, almost every navigation resolves inside the fallback delay and no fallback is ever shown. This is why the flicker problem is invisible in development.
- On a slow network, boundary placement becomes the dominant part of the experience: a retained view with a progress bar is usable while a blanked page is not.
- On a slow device, the fallback itself competes for the main thread with the render of the content it is standing in for, so an elaborate animated skeleton can delay the thing it is waiting for (Long Tasks).
- With server rendering and streaming, boundaries become flush points: the server can send the shell immediately and stream each region as it resolves, which changes the calculus for how many boundaries you want (Streaming Server Rendering).
- On a repeat navigation with a warm cache, the right behaviour is no loading state at all — the data is already there and any fallback is a regression (Stale-While-Revalidate).
- Retaining the old view is better on a slow network and costs a real ambiguity: the user is looking at content that no longer matches the URL. That gap has to be visible, or it is a lie.
- More boundaries give finer control and more places for the page to assemble visibly. Fewer boundaries give a coherent transition and coarser failure granularity.
- Delaying the fallback removes flicker and makes genuinely slow navigations feel slightly slower, because feedback starts later.
- Skeletons that match the content exactly are the best answer to layout shift and are a second layout to maintain: every change to the real component is a change to its placeholder (Design Systems).
- Hoisting data to the route removes waterfalls and couples the route to what its children need, which is a real loss of component independence (Drawing Component Boundaries).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- FRAMEWORK-SPECIFICThe primitive has different names and different capabilities: React exposes suspense boundaries plus a transition API that retains the old UI while the next one prepares; Vue has a suspense component; SvelteKit and Remix-style routers express loading at the route level with their own pending semantics. What is universal is the placement question — which subtree may be replaced — not the API.
- GENERALThe underlying trade-off is not framework-dependent at all. Replacing a subtree with a fallback versus retaining stale content with a progress indicator is the same decision a plain
fetchand aninnerHTMLswap would face. - NETWORK-SPECIFICOn a fast connection almost every navigation resolves inside the fallback delay and boundary placement is invisible; on a slow or high-latency connection it becomes the dominant characteristic of the application. Tune against throttled conditions, not local ones.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — pending and failed states are the two hardest states to reach in a test and the two most users will see; a router without an injectable delay and an injectable failure has states nobody can exercise.