suspense-patterns — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited suspense-patterns (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
What it is: React Suspense boundary design: deciding which subtree waits, which fallback appears, and which content reveals together or progressively when code, data, or a Server Component render suspends.
Mental model: Suspense is boundary-based coordination, not component-local isLoading state. A Suspense-enabled data source, lazy component, use(promise) call, or async server render suspends; the nearest Suspense boundary chooses the placeholder and reveal grouping.
Why it exists: Loading states become brittle when every component owns its own spinner. Suspense moves the loading decision up the tree, so product hierarchy controls the loading sequence and the component that needs data can read as if the data is ready.
What it is NOT: Not general rendering-strategy selection, not Server Component placement, not ordinary hook dependency design, not non-React streaming transport design, and not standalone error-boundary design.
Adjacent concepts: Error boundaries, React transitions, deferred values, React.lazy, React 19 use, Server Components, streaming server rendering, Next.js route-segment loading UI, partial prerendering, selective hydration.
One-line analogy: Suspense boundaries are theater curtains: the performer signals "not ready," and the curtain decides what the audience sees until that part of the stage is ready.
Common misconception: Suspense does not make every async fetch suspend automatically. It only responds to Suspense-enabled sources; data fetched in an Effect or event handler still needs local state or a framework/library that integrates with Suspense.
The discipline of placing and pairing Suspense boundaries: how a Suspense boundary catches a suspended descendant and renders a fallback, how boundary placement determines what waits for what, how Suspense composes with error boundaries (and why they cannot be the same component), how useTransition, startTransition, and useDeferredValue change the boundary's behavior on updates, how Suspense for code splitting (React.lazy) and Suspense for data fetching share the same boundary mechanism, and how Next.js App Router's segment-level loading file is a route-level Suspense convention. The skill covers React 18+ semantics throughout and the React 19 use hook that lets Client Components unwrap Promises directly.
The classic React loading-state pattern was conditional rendering: each component owned an isLoading flag, returned a spinner or skeleton, and re-rendered when its data resolved. That pattern is local and imperative. It puts every component in the position of deciding how to communicate "I'm waiting" and where in its render output the placeholder goes. It does not compose; two sibling components loading in parallel produce two independent spinners with no coordinated layout.
Suspense inverts the responsibility. A Suspense-enabled descendant suspends by lazy-loading code, reading a cached Promise with use, using a Suspense-enabled framework/library data source, or encountering unresolved async work in a server render. The Suspense boundary catches that suspension and renders a fallback for the entire subtree. The component itself only has to declare "I need this data" when it is using a Suspense-enabled source -- never "I might not have it yet." The boundary, declared once, owns the placeholder UI for everything below it. The semantics compose: nested Suspense boundaries let outer fallbacks resolve first while inner sections continue to wait.
This is a different mental model than try/catch for asynchronicity. The unresolved resource is not an error -- it is a signal that "this render cannot complete yet, please show the nearest Suspense fallback and try again when the resource is ready." React reconciles the wait at the boundary; the component code reads as if the data were already there.
React's own boundary is deliberately narrower than "any async work." Suspense does not detect data fetched inside an Effect or event handler, and unsupported hand-rolled data sources are not a stable API surface. Use a Suspense-enabled framework, a library that implements the convention, code splitting through lazy, or React's use API for cached Promises.
The design discipline of Suspense is therefore about boundary placement, not about loading-state plumbing. The questions are: what content should appear together? (one boundary around them all) and what content should stream in independently? (separate boundaries around each). The boundary is a hierarchy decision in the UI, not a state machine inside a component.
A Suspense boundary responds when any Suspense-enabled descendant suspends during render. The boundary's job is to define a grouping: "these things appear together, and either they all show or the fallback shows."
Three placement strategies, each correct in different contexts:
1. Page-level boundary. One boundary near the root. The whole page shows a fallback until everything inside resolves.
<Suspense fallback={<PageSkeleton />}>
<Header />
<Dashboard />
<Footer />
</Suspense>2. Section-level boundaries. A boundary around each independent section.
<Header />
<Suspense fallback={<ChartSkeleton />}>
<Chart /> {/* slow query */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<Table /> {/* faster query */}
</Suspense>
<Footer />3. Leaf-level boundaries. A boundary on each loading-aware component itself.
<UserList>
{users.map(user => (
<Suspense key={user.id} fallback={<UserCardSkeleton />}>
<UserCard userId={user.id} />
</Suspense>
))}
</UserList>The wrong placement is usually too coarse: a single boundary near the page root waits for everything, defeating the streaming benefit. Less commonly, a boundary too fine produces a busy "many spinners" UI. The right granularity matches the page's actual visual hierarchy.
Suspense alone does not prevent waterfalls. A waterfall is when fetch B starts only after fetch A resolves, sequentially, when both could have started together.
// WATERFALL — parent awaits, then child fetches
async function ParentBad() {
const a = await fetchA()
return <ChildBad a={a} /> // ChildBad starts fetch B only after this point
}
// PARALLEL — both fetches kicked off at the same render level
async function ParentGood() {
const aPromise = fetchA()
const bPromise = fetchB()
const [a, b] = await Promise.all([aPromise, bPromise])
return <Child a={a} b={b} />
}
// SUSPENSE-PARALLEL — siblings under separate Suspense boundaries
function ParentStreaming() {
return (
<>
<Suspense fallback={<A_Skeleton />}><A /></Suspense>
<Suspense fallback={<B_Skeleton />}><B /></Suspense>
</>
)
}The pattern: kick off all independent fetches at the same level of the tree, then either await Promise.all if you need both before rendering, or wrap each child in its own Suspense boundary if they can stream in independently. A child that fetches its own data is fine — as long as the parent doesn't `await` something the child depends on first. Tree depth correlates with waterfall risk; flat trees with siblings starting requests in parallel correlate with optimal streaming.
A Suspense boundary catches render-time suspension signals from Suspense-enabled sources, not thrown Errors. An error boundary catches thrown Errors, not loading-state suspension. They are different React mechanisms that solve adjacent problems and must be different components, but they almost always pair together.
The canonical pair:
<ErrorBoundary fallback={<ErrorUI />}>
<Suspense fallback={<LoadingUI />}>
<DataLoadingComponent />
</Suspense>
</ErrorBoundary>Order matters:
Most applications want ErrorBoundary on the outside so a failure replaces the loading state cleanly. Putting ErrorBoundary on the inside is appropriate when the loading state should remain visible and only a small portion of the subtree should swap to error UI.
React does not ship a built-in ErrorBoundary for function components (as of React 19). Use react-error-boundary (Kent C. Dodds) or implement a class component. Server Components' errors are caught by Next.js's error.tsx file, which is essentially a route-scoped ErrorBoundary.
useTransition, startTransition, and "Stale" BoundariesBy default, a Suspense boundary unmounts its children and shows the fallback whenever a descendant suspends during an urgent update -- even on subsequent updates (e.g., a tab switch that triggers a new Suspense-enabled fetch). This causes the "spinner flicker" anti-pattern: every interaction shows the loading state.
useTransition (and startTransition) mark an update as non-urgent. React holds the previous render visible while the new render's data is loading, instead of unmounting to the fallback:
function TabPanel({ activeTab }) {
return <Content tab={activeTab} /> // suspends on tab change
}
function Tabs() {
const [tab, setTab] = useState('a')
const [isPending, startTransition] = useTransition()
return (
<>
<button onClick={() => startTransition(() => setTab('b'))}>
{isPending ? 'Loading…' : 'Tab B'}
</button>
<Suspense fallback={<Skeleton />}>
<TabPanel activeTab={tab} />
</Suspense>
</>
)
}On the first render, the Suspense fallback shows (no previous render to keep). On subsequent tab changes triggered through startTransition, the previous tab's content stays mounted while the new tab loads, and isPending indicates the transition is in flight. The fallback only shows when there is no prior committed render to preserve.
Design rule: wrap user-initiated updates that may trigger Suspense in startTransition to avoid flickering fallbacks. Do not wrap initial-load updates in startTransition, since there is no previous content to preserve and the boundary will simply not show the fallback when you want it to.
In an RSC tree, Suspense controls streaming HTML granularity. The server flushes everything outside the boundary first, then streams in each boundary's content as it resolves:
// Example Server Component route page
export default async function Dashboard() {
return (
<>
<Header /> {/* renders and flushes immediately */}
<Suspense fallback={<Skeleton />}>
<SlowSection /> {/* streams in when data resolves */}
</Suspense>
</>
)
}Next.js App Router provides a segment-local loading file as sugar for a route-level Suspense boundary. That file becomes the fallback for an implicit <Suspense> around the segment page and nested children below it, while the same segment's layout, template, and error file sit outside that implicit boundary. Identical semantics can be expressed with an explicit <Suspense> inside a layout or page when the loading state should be more granular.
The interaction between RSC and Suspense is one of the biggest design wins of the App Router: server-rendered content can stream in chunks without giving up server-side rendering for the initial-paint content. The discipline of server-components-design (where to draw the server/client boundary) and suspense-patterns (where to draw the streaming boundary) compose together — they are orthogonal axes on the same tree.
See server-components-design for the discipline of what runs on the server side of the RSC boundary; this skill covers how to chunk the streaming output of that server tree.
use HookReact 19 adds use(promise) — a hook (with relaxed rules: can be called conditionally) that unwraps a Promise during render. Inside a Suspense boundary, use is how Client Components participate in data-fetching Suspense without throwing manually:
'use client'
import { use } from 'react'
function Comments({ commentsPromise }) {
const comments = use(commentsPromise) // suspends until resolved
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>
}
// In a Server Component:
export default function Post() {
const commentsPromise = fetchComments() // not awaited — passed as Promise
return (
<Suspense fallback={<Spinner />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
)
}The Promise is created in the Server Component (which is free to start the fetch eagerly), passed across the server/client boundary, and unwrapped in the Client Component via use. The resolved value must be serializable between server and client. The Server Component does not block on the fetch; the Suspense boundary streams or hydrates the resolved subtree when the Promise settles.
Before React 19, Client Components could not directly consume Suspense for data fetching without a library that implements the throw-a-Promise convention (React Query's Suspense mode, SWR with suspense: true, Relay). use makes the pattern first-class.
| Anti-pattern | Why it's wrong | Fix |
|---|---|---|
| One Suspense boundary near the page root | Fallback replaces the entire page even though most is fast | Move boundaries closer to the slow data, one per independent slow section |
| Assuming Suspense catches Effect/event-handler fetches | The boundary never activates because the fetch did not suspend during render | Use a Suspense-enabled framework/library, lazy, or use(promise); otherwise keep local loading state |
| ErrorBoundary inside Suspense for a group-level data failure | Loading and failure states compete for ownership | Put ErrorBoundary outside Suspense when the whole group should swap from loading to error |
| Awaiting parent data, then child fetches its own | Sequential waterfall | Kick off both fetches at the same level, pass Promises down or use Promise.all |
| Suspense fallback shows on every interaction | Fallback flickers on updates | Wrap user-initiated updates in startTransition / useTransition |
| Skeleton fallback that doesn't match the real content's layout | Layout shift when content arrives | Match skeleton dimensions to the real content (use same width/height/padding) |
Both Suspense and an isLoading prop in the same component | Duplicate loading-state plumbing | Pick one — Suspense throws or local state, not both |
| Suspense around the whole application layout | Every navigation shows the page-level fallback | Place fallback at the route segment or feature level |
Calling useSearchParams() in a static Client subtree without Suspense | The route can bail out to client rendering or fail production static builds | Wrap the smallest Client subtree that reads search params in Suspense |
| Runtime layout data with only a segment loading file | The implicit loading boundary does not cover that layout work | Move runtime data into the page/subtree or add an explicit Suspense boundary around the layout's runtime part |
After applying this skill, verify:
startTransition to prevent fallback flicker.isLoading flag duplicates work that the Suspense boundary already handles.<Suspense>. The official reference for the boundary and its semantics.useTransition and startTransition. The transition APIs that prevent fallback flicker.use. The React 19 Promise-unwrapping hook for Client Components.loading.tsx conventions. The route-segment Suspense sugar.| Instead of this skill | Use | Why |
|---|---|---|
| Choosing between SSR, SSG, ISR, and CSR rendering strategies | rendering-models | rendering-models owns the strategic decision; suspense-patterns is an in-tree mechanism that works with several of those strategies. |
| Designing which work runs as Server Components vs Client Components | server-components-design | server-components-design owns the server/client tree split; suspense-patterns is orthogonal — Suspense boundaries can live in either kind of component. |
| Hook discipline on Client Components (Rules of Hooks, useEffect, useMemo) | hooks-patterns | hooks-patterns covers the in-component logic primitives; Suspense operates at the tree level above hooks. |
| Streaming protocols broader than React Suspense (SSE, WebSocket, AI streaming) | streaming-architecture | streaming-architecture is the general protocol concern; React Suspense streaming is one application of the broader streaming toolkit. |
| Error-handling patterns (try/catch, error boundaries in isolation) | code-review and react-error-boundary library docs | This skill covers Suspense+ErrorBoundary pairing; isolated error-boundary discipline lives elsewhere. |
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.