Async server component without Suspense boundary
In the App Router and React 19, an async server component with no enclosing <Suspense> boundary blocks the whole route from streaming. Users wait for the slowest fetch before seeing anything: no shell, no skeleton, no progressive paint. Wrap the slow async tree in <Suspense fallback={...}> to unblock the rest of the page.
What goes wrong
Page renders four widgets; one calls a slow third-party API. With no Suspense boundary, the whole route hangs on that fetch. TTFB explodes, the user sees a blank screen, and nothing paints until the slowest dependency resolves.
Detection
Surfaces: dashboard, list, search, loading-state, checkout, onboarding (anywhere server-fetched data renders).
Static signals:
- Find async server components:
rg '^export (default )?async function' --type=ts app/ src/app/. - For each, walk up the tree to check whether the parent renders it inside
<Suspense>. - Confirm the route segment lacks a
loading.tsx(Next.js auto-wraps the segment in Suspense when present). - Flag if neither a
<Suspense>ancestor nor a siblingloading.tsxexists.
Concrete commands:
# Async server components in the App Router
rg '^export (default )?async function' --type=ts app/ src/app/ -l
# Files that import Suspense
rg "from 'react'" --type=ts -l | while read f; do
rg -l 'Suspense' "$f"
done
# Routes missing loading.tsx
find app src/app -type f -name 'page.tsx' 2>/dev/null | while read p; do
dir=$(dirname "$p")
[ ! -f "$dir/loading.tsx" ] && echo "$dir: no loading.tsx"
doneFalse-positive guards:
- Skip if a
loading.tsxexists at any ancestor segment; Next.js wraps the segment in Suspense automatically. - Skip if the component is a leaf and renders inline static markup (no awaits).
- Skip files annotated
// ui-audit-ignore:async-no-suspense-boundary.
Fix
Wrap the slow async tree in <Suspense> with a skeleton fallback that matches the loaded layout (CLS-safe).
// before: entire dashboard waits for SlowWidget
export default async function DashboardPage() {
return (
<main>
<FastWidget />
<SlowWidget /> {/* awaits a 3s API */}
<AnotherWidget />
</main>
);
}
// after: SlowWidget streams in; rest of the page paints immediately
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<main>
<FastWidget />
<Suspense fallback={<SlowWidgetSkeleton />}>
<SlowWidget />
</Suspense>
<AnotherWidget />
</main>
);
}Docs:
- React: https://react.dev/reference/react/Suspense
- Next.js streaming: https://nextjs.org/docs/app/getting-started/fetching-data
- Next.js loading.tsx: https://nextjs.org/docs/app/api-reference/file-conventions/loading
Default tier and overrides
Defaults to: fix-this-sprint
Surface overrides:
| Surface | Tier |
|---|---|
| Dashboard with >2 widgets | release-blocker |
| Checkout / Sign-in | release-blocker (TTFB on critical path) |
| Marketing landing | fix-this-sprint |
| Internal admin | backlog |
Defer-to (when this is another tool's job)
- Lighthouse for measured TTFB / LCP regressions.
- Vercel Speed Insights for field measurement of streaming impact.
- ESLint plugin
eslint-plugin-react-server-componentsfor write-time checks.
Suppression
{/* ui-audit-ignore:async-no-suspense-boundary, leaf is sync, no await */}
<StaticWidget />