Suspense Boundaries Placement
Strategic positioning of Suspense components for optimal loading UX
Suspense Boundaries Placement
Master strategic Suspense boundary placement with free flashcards and spaced repetition practice. This lesson covers boundary granularity, component hierarchy considerations, and performance trade-offsβessential concepts for building responsive Relay applications with optimal user experience.
Welcome
Welcome to one of the most critical architectural decisions in Relay applications! π― Suspense boundaries are like safety nets in a circus performanceβplace them too high, and small falls become catastrophic; place them too low, and performers can't take any risks. In React with Relay, Suspense boundaries determine where loading states appear, how your UI responds to data fetching, and ultimately, how users perceive your application's performance.
This lesson will transform how you think about loading states, moving from "spinners everywhere" to strategic, user-centric data boundaries. By the end, you'll understand not just where to place Suspense boundaries, but why each placement decision matters.
Core Concepts
What Are Suspense Boundaries?
Suspense boundaries are React components that catch "suspensions" (promises thrown by child components) and display fallback UI while data loads. Think of them as defensive programming for asynchronous operations. π»
βββββββββββββββββββββββββββββββββββββββ β}> β β Boundary β β β βββββββββββββββββββββββββββ β β β Component using data β β β β (may suspend) β β β βββββββββββββββββββββββββββ β β β β β βββββββββββββββββββββββββββββββββββββββ If child suspends β fallback shows If child renders β content shows
In Relay, when you use hooks like usePreloadedQuery or useFragment, React may suspend if data isn't ready. The nearest parent Suspense boundary catches this suspension.
Key principle: Suspense boundaries define your loading granularityβthe size of UI chunks that load together.
The Granularity Spectrum
π‘ Boundary granularity is the most important placement decision. It exists on a spectrum:
| Coarse (Few Boundaries) | Medium (Balanced) | Fine (Many Boundaries) |
|---|---|---|
| β Simple mental model | β Balanced UX | β Granular feedback |
| β Fewer loading states | β Progressive disclosure | β Fast perceived performance |
| β All-or-nothing loading | β Reasonable complexity | β Complex coordination |
| β Slower perceived load | β Requires planning | β Layout shift risks |
Coarse example: One boundary around entire page
<Suspense fallback={<PageLoader />}>
<EntirePage />
</Suspense>
Fine example: Boundaries around each section
<>
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</>
Boundary Placement Hierarchy
Think of your component tree as having data boundaries that naturally suggest Suspense placement:
COMPONENT TREE WITH DATA NEEDS
App
β
ββ Route (navigation boundary) β β Natural boundary
β β
β ββ Page
β β β
β β ββ Header (independent data) β β Consider boundary
β β β
β β ββ MainContent (critical data) β β Consider boundary
β β β β
β β β ββ Post
β β β ββ Comments (optional data) β β Consider boundary
β β β ββ RelatedPosts
β β β
β β ββ Sidebar (independent data) β β Consider boundary
β β
β ββ Footer (static)
β
ββ AnotherRoute
Natural boundary indicators:
- π Route/navigation changes: Almost always need boundaries
- π― Independent data requirements: Components fetching unrelated data
- β±οΈ Different latency expectations: Fast vs. slow data sources
- π Optional/supplementary content: Nice-to-have vs. critical
- π¨ Visual independence: Sections that can load separately without confusion
The Critical vs. Optional Pattern
One of the most powerful patterns is distinguishing critical from optional content:
// Critical content blocks until ready (coarse boundary)
<Suspense fallback={<CriticalContentSkeleton />}>
<ArticleTitle />
<ArticleBody />
<AuthorInfo />
</Suspense>
// Optional content loads independently (fine boundaries)
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* Can be slow */}
</Suspense>
<Suspense fallback={<AdsSkeleton />}>
<Advertisements /> {/* External API */}
</Suspense>
Why this works: Users get critical content quickly while optional sections load in the background. The page feels fast because the most important content appears first.
Performance Trade-offs
Every boundary placement involves trade-offs. Understanding these helps you make informed decisions:
π Performance Trade-off Matrix
| Aspect | Fewer Boundaries | More Boundaries |
|---|---|---|
| Time to Interactive | β οΈ Slower (wait for all) | β Faster (progressive) |
| Perceived Performance | β οΈ Feels slower | β Feels faster |
| Layout Shifts | β Fewer shifts | β οΈ More shifts |
| Complexity | β Simpler | β οΈ More complex |
| Bundle Size | β Smaller (fewer fallbacks) | β οΈ Larger (many fallbacks) |
| Error Isolation | β οΈ One failure = blank page | β Failures are isolated |
Real-world impact: A social media feed with one boundary at the top means users see nothing until everything loads (potentially 2-3 seconds). With boundaries around each post, the first post appears in ~300ms, creating a much better experience.
The Waterfall Problem
β‘ Waterfalls occur when components nested inside Suspense boundaries trigger additional suspensions sequentially:
WATERFALL ANTI-PATTERN Time βββββββ Total time = P + C + G (sequential) ββ P β loads Parent data βββ¬βββ β ββββββ βββ¬βββ β βββββββββ C β loads Child data βββ G β loads Grandchild data ββββββ
Solution: Use @defer or fetch data at the same level:
PARALLEL LOADING (CORRECT) Time βββββββ ββββββ€ β G β ββββββ Total time = max(P, C, G) (parallel) ββ P β ββββββ€ All load in parallel β C β
π‘ Pro tip: Use Relay's @defer directive to explicitly control when nested data loads, avoiding accidental waterfalls.
Route-Level vs. Component-Level Boundaries
Route-level boundaries wrap entire pages:
<Route path="/profile/:id" element={
<Suspense fallback={<PageSpinner />}>
<ProfilePage />
</Suspense>
} />
Pros: Simple, clean navigation experience
Cons: Entire page reloads on navigation, even if sidebar/header could persist
Component-level boundaries are more granular:
function ProfilePage() {
return (
<>
<ProfileHeader /> {/* Loaded at route level */}
<Suspense fallback={<TabsSkeleton />}>
<ProfileTabs /> {/* Loads independently */}
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed /> {/* Loads independently */}
</Suspense>
</>
);
}
Pros: Granular loading, better perceived performance
Cons: More complex, requires careful design
Best practice: Combine both! Route-level for critical "above-the-fold" content, component-level for optional sections.
Detailed Examples
Example 1: Social Media Feed
Let's design Suspense boundaries for a Twitter-like feed:
function FeedPage() {
// β WRONG: Single boundary - all-or-nothing
return (
<Suspense fallback={<FullPageSpinner />}>
<Navigation />
<CreatePostBox />
<FeedList />
<TrendingSidebar />
<RecommendedUsers />
</Suspense>
);
}
Problem: Users see nothing until everything loads. If TrendingSidebar hits a slow API, the entire page is blocked.
function FeedPage() {
// β
CORRECT: Strategic boundaries
return (
<>
{/* Critical UI - loads fast, blocks render */}
<Suspense fallback={<NavSkeleton />}>
<Navigation />
<CreatePostBox />
</Suspense>
{/* Main content - important but can show progressively */}
<Suspense fallback={<FeedSkeleton />}>
<FeedList />
</Suspense>
{/* Sidebar - optional, independent loading */}
<div style={{ display: 'flex', gap: '16px' }}>
<Suspense fallback={<TrendingSkeleton />}>
<TrendingSidebar />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendedUsers />
</Suspense>
</div>
</>
);
}
Why this works:
- Navigation appears in ~200ms (fast, critical)
- Feed starts rendering posts as they arrive
- Sidebar loads independentlyβif it's slow, main content isn't blocked
- Users can start reading immediately
Relay query structure:
query FeedPageQuery {
viewer {
...Navigation_user
...CreatePostBox_user
}
feed(first: 10) @connection(key: "Feed_feed") {
edges {
node {
...FeedPost_post
}
}
}
trending @defer { # Sidebar loads separately
topics {
...TrendingSidebar_topics
}
}
recommendations @defer {
users {
...RecommendedUsers_users
}
}
}
π‘ Notice: @defer directive tells Relay these can load after initial render.
Example 2: E-commerce Product Page
Product pages have distinct data dependencies with different priorities:
function ProductPage({ productId }) {
// Route-level: Must load before page renders
const queryRef = useQueryLoader(ProductPageQuery, { productId });
return (
<Suspense fallback={<ProductPageSkeleton />}>
<ProductPageContent queryRef={queryRef} />
</Suspense>
);
}
function ProductPageContent({ queryRef }) {
const data = usePreloadedQuery(ProductPageQuery, queryRef);
return (
<>
{/* Critical: Product info (above fold) - no extra boundary */}
<ProductImages images={data.product.images} />
<ProductTitle title={data.product.title} />
<ProductPrice price={data.product.price} />
<AddToCartButton productId={data.product.id} />
{/* Optional: Reviews (below fold) - independent boundary */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewsSection productId={data.product.id} />
</Suspense>
{/* Optional: Recommendations (below fold) - independent boundary */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RelatedProducts categoryId={data.product.category.id} />
</Suspense>
{/* Optional: Recently viewed (below fold) - independent boundary */}
<Suspense fallback={<RecentlyViewedSkeleton />}>
<RecentlyViewed userId={data.viewer.id} />
</Suspense>
</>
);
}
Design rationale:
- Route boundary: Ensures critical product data (images, price, title) loads togetherβusers need this to make decisions
- No boundary on product basics: These must appear simultaneously for coherent UX
- Reviews boundary: Reviews can be slow (complex aggregation), shouldn't block purchase
- Separate boundaries for recommendations: These call different services, independent failures shouldn't cascade
Performance outcome:
- Time to interactive: ~400ms (critical data only)
- Full page load: ~1.2s (including all sections)
- Perceived performance: Excellentβusers can add to cart while reviews load
Example 3: Dashboard with Multiple Widgets
Dashboards present unique challengesβmultiple independent data sources:
function Dashboard() {
return (
<DashboardLayout>
{/* Header is critical and fast */}
<Suspense fallback={<DashboardHeaderSkeleton />}>
<DashboardHeader />
</Suspense>
<DashboardGrid>
{/* Each widget gets its own boundary - they're independent */}
<Suspense fallback={<WidgetSkeleton />}>
<SalesWidget /> {/* Fast API */}
</Suspense>
<Suspense fallback={<WidgetSkeleton />}>
<RevenueWidget /> {/* Fast API */}
</Suspense>
<Suspense fallback={<WidgetSkeleton />}>
<AnalyticsWidget /> {/* Slow - heavy computation */}
</Suspense>
<Suspense fallback={<WidgetSkeleton />}>
<UserActivityWidget /> {/* Medium speed */}
</Suspense>
<Suspense fallback={<WidgetSkeleton />}>
<NotificationsWidget /> {/* Fast API */}
</Suspense>
<Suspense fallback={<WidgetSkeleton />}>
<SystemHealthWidget /> {/* External service - variable */}
</Suspense>
</DashboardGrid>
</DashboardLayout>
);
}
Why individual boundaries:
- Independent failures: If AnalyticsWidget crashes, other widgets still render
- Progressive rendering: Fast widgets (Sales, Revenue, Notifications) appear immediately while AnalyticsWidget computes
- Parallel fetching: All queries fire simultaneously, no waterfalls
- User perception: Dashboard feels "alive" as widgets pop in
LOADING TIMELINE (with individual boundaries)
0ms ββββββββββββββββββββββββββββββββββββββ
β π¨ Layout renders immediately β
100ms β β Header β
β β³β³β³β³β³β³ (6 widgets loading) β
250ms β β Sales β Revenue β Notificationsβ
β β³ Analytics β³ Activity β³ Health β
500ms β β Activity β
750ms β β Health β
1200ms β β Analytics β
ββββββββββββββββββββββββββββββββββββββ
User can interact with 3 widgets at 250ms! β
Alternative (single boundary):
// β ANTI-PATTERN
<Suspense fallback={<EntireDashboardSkeleton />}>
<DashboardHeader />
<SalesWidget />
<RevenueWidget />
<AnalyticsWidget /> {/* Blocks everything */}
<UserActivityWidget />
<NotificationsWidget />
<SystemHealthWidget />
</Suspense>
LOADING TIMELINE (with single boundary)
0ms ββββββββββββββββββββββββββββββββββββββ
β β³ NOTHING visible β
β β³ Waiting for ALL data... β
β β³ Blocked by slowest widget β
1200ms β β Everything appears at once β
ββββββββββββββββββββββββββββββββββββββ
User waits 1200ms to see ANYTHING β
Example 4: Nested Navigation (Sidebar + Content)
Complex apps often have nested navigation requiring careful boundary design:
function AppLayout() {
return (
<div className="app-layout">
{/* Sidebar: Independent of main content */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar>
{/* Sidebar items can load progressively */}
<Suspense fallback={<TeamsSkeleton />}>
<TeamsList /> {/* User's teams */}
</Suspense>
<Suspense fallback={<ProjectsSkeleton />}>
<ProjectsList /> {/* User's projects */}
</Suspense>
<Suspense fallback={<NotificationsBadgeSkeleton />}>
<NotificationsBadge /> {/* Live updates */}
</Suspense>
</Sidebar>
</Suspense>
{/* Main content: Route-specific, independent of sidebar */}
<main>
<Suspense fallback={<MainContentSkeleton />}>
<Outlet /> {/* React Router outlet */}
</Suspense>
</main>
</div>
);
}
Key insight: Sidebar and main content are visually and functionally independent. Users expect:
- Sidebar to persist across navigation
- Main content to update when routes change
- Neither to block the other
Navigation behavior:
// When user clicks link in sidebar...
<Link to="/projects/123">
Project Alpha
</Link>
// Only main content boundary suspends
// Sidebar stays visible and interactive β
What happens:
- User clicks link
- Route changes to
/projects/123 <Outlet />(main content) suspends and shows<MainContentSkeleton />- Sidebar remains fully rendered and interactive
- New project data loads
- Main content updates
Without separate boundaries:
// β WRONG
<Suspense fallback={<FullPageLoader />}>
<Sidebar />
<Outlet />
</Suspense>
// When route changes, ENTIRE PAGE flashes to loader
// Sidebar disappears even though it didn't change β
Common Mistakes
β Mistake 1: Too Few Boundaries (The "Big Blob")
// β WRONG: Everything in one boundary
<Suspense fallback={<Spinner />}>
<Header />
<Navigation />
<MainContent />
<Sidebar />
<Footer />
<Ads />
<Analytics />
<SocialWidgets />
</Suspense>
Why it's bad: If Ads (external API) is slow, users see nothing. All-or-nothing loading creates terrible UX.
Fix: Separate critical from optional:
<Suspense fallback={<HeaderSkeleton />}>
<Header />
<Navigation />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
<Ads />
<SocialWidgets />
</Suspense>
β Mistake 2: Too Many Boundaries (The "Spinner Forest")
// β WRONG: Boundary around every tiny component
<Suspense fallback={<Spinner />}>
<Title />
</Suspense>
<Suspense fallback={<Spinner />}>
<Subtitle />
</Suspense>
<Suspense fallback={<Spinner />}>
<Author />
</Suspense>
<Suspense fallback={<Spinner />}>
<Date />
</Suspense>
<Suspense fallback={<Spinner />}>
<Content />
</Suspense>
Why it's bad:
- Dozens of spinners flashing creates visual chaos
- Components that should load together are artificially separated
- Increased complexity with minimal benefit
Fix: Group related content:
<Suspense fallback={<ArticleHeaderSkeleton />}>
<Title />
<Subtitle />
<Author />
<Date />
</Suspense>
<Suspense fallback={<ArticleContentSkeleton />}>
<Content />
</Suspense>
β Mistake 3: Nested Boundaries Creating Waterfalls
// β WRONG: Nested data fetching
function Parent() {
const data = usePreloadedQuery(ParentQuery, queryRef);
return (
<Suspense fallback={<Spinner />}>
<Child parentId={data.parent.id} /> {/* Will suspend again! */}
</Suspense>
);
}
function Child({ parentId }) {
const data = useQuery(ChildQuery, { parentId }); // Waterfall!
// ...
}
Why it's bad: Child can't start loading until Parent finishes. Sequential loading is slow.
Fix: Fetch together or use @defer:
// β
CORRECT: Single query with fragments
query ParentQuery {
parent {
id
...Parent_parent
child {
...Child_child # Loads in same request
}
}
}
// OR with @defer
query ParentQuery {
parent {
id
...Parent_parent
child @defer {
...Child_child # Loads separately but Relay handles it
}
}
}
β Mistake 4: Forgetting Error Boundaries
// β WRONG: No error handling
<Suspense fallback={<Spinner />}>
<ComponentThatMightFail />
</Suspense>
// If component throws, entire app crashes!
Fix: Pair Suspense with Error Boundaries:
// β
CORRECT
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Spinner />}>
<ComponentThatMightFail />
</Suspense>
</ErrorBoundary>
β‘ Remember: Suspense catches promises (loading), Error Boundaries catch errors. You need both!
β Mistake 5: Inconsistent Fallback Sizes
// β WRONG: Fallback smaller than content
<Suspense fallback={<div>Loading...</div>}> {/* 20px tall */}
<LargeTable /> {/* 500px tall */}
</Suspense>
// Causes massive layout shift when content loads!
Fix: Match fallback size to content:
// β
CORRECT
<Suspense fallback={
<div style={{ height: '500px' }}>
<TableSkeleton rows={10} />
</div>
}>
<LargeTable />
</Suspense>
π‘ Pro tip: Use skeleton screens that mirror your content's structure and dimensions.
Key Takeaways
π Suspense Boundary Placement Quick Reference
| Scenario | Recommended Placement | Reason |
|---|---|---|
| Route changes | Route-level boundary | Clean navigation UX |
| Critical content | Single coarse boundary | Appears together, coherent |
| Optional content | Individual fine boundaries | Non-blocking, progressive |
| Dashboard widgets | Boundary per widget | Independent, parallel loading |
| Sidebar + content | Separate boundaries | Independent updates |
| List items | Boundary around list, not items | Avoid spinner forest |
| Nested data | Parent-level boundary + @defer | Prevent waterfalls |
The Golden Rules: π
Start coarse, refine as needed: Begin with route-level boundaries, add component-level only where beneficial
Think user perception, not technical purity: Place boundaries where they create the best feeling of speed
Critical together, optional separate: Group critical content, isolate optional content
One boundary per independent data source: If two components fetch from different APIs, they probably need separate boundaries
Match fallback to content: Skeleton screens should match rendered content dimensions to avoid layout shift
Always pair with Error Boundaries: Suspense handles loading, Error Boundaries handle failuresβyou need both
Avoid waterfalls: Nested boundaries are fine, but ensure data fetching happens in parallel (use
@defer)Test slow networks: Your boundary strategy only reveals itself under slow/unreliable connectionsβtest with throttling!
Decision Framework: π€
When deciding boundary placement, ask:
- Independence: Can this load separately without confusing users?
- Priority: Is this critical (must appear together) or optional (can appear later)?
- Latency: Does this hit a slow API/service?
- Failure tolerance: If this fails, should other content still render?
- Visual coherence: Do users expect these elements to appear together?
If you answered "yes" to questions 1, 3, or 4, and "no" to questions 2 or 5, consider a separate boundary.
Debugging Tips: π§
When your boundaries aren't working as expected:
- Use React DevTools Profiler: See which components are suspending and when
- Add logging to fallbacks:
<Suspense fallback={<Spinner onMount={() => console.log('Suspended!')} />}> - Check Network tab: Verify queries fire in parallel, not sequentially
- Test with Cache.restore(): Ensure navigation doesn't unnecessarily re-suspend
- Use Relay DevTools: Inspect query execution timing and cache hits
π Further Study
React Documentation - Suspense: https://react.dev/reference/react/Suspense - Official React guide to Suspense boundaries, including patterns and best practices
Relay Documentation - @defer and @stream: https://relay.dev/docs/guided-tour/list-data/streaming-pagination/ - How to use Relay directives to control progressive data loading and avoid waterfalls
Web.dev - Optimize Largest Contentful Paint: https://web.dev/optimize-lcp/ - Understanding how boundary placement affects Core Web Vitals and perceived performance metrics
You've mastered Suspense boundary placement! π You now understand the strategic decisions behind loading states, from coarse route-level boundaries to fine-grained component boundaries. Remember: the best boundary placement is invisible to usersβthey just feel like your app is fast. Practice with different scenarios, test under slow networks, and always prioritize user perception over technical perfection. Happy coding! π»β¨