Streaming with Suspense: real-world lessons and examples. Show the page instantly and stream slow parts in.
The Problem: One Slow Query Blocks the Entire Page
In production dashboards across our e-commerce and SaaS platforms, we've all hit the same wall. A user lands on a reporting dashboard, and the entire page hangs. A single database query—maybe it's calculating revenue across three months, or fetching user analytics—takes eight seconds. Meanwhile, the header, sidebar, and half the widgets that could render instantly sit behind a loading spinner.
This is the classic waterfall problem. In traditional React rendering, you fetch data at the root level, wait for all data to arrive, then render the entire tree. If one query is slow, every component downstream waits. Users see a blank screen, bounce rates spike, and your Lighthouse scores tank.
The impact is measurable. We've observed time-to-first-content delays of 5–10 seconds in production dashboards when a single slow analytics query blocks page render. Users don't care that the revenue chart takes time; they want to see something immediately.
Wrap Slow Parts: Suspense Boundaries Stream Content as It Becomes Ready
React Server Components and Streaming with Suspense solve this by letting you split your render tree into independent chunks. Instead of waiting for all data, you send the fast parts immediately and stream the slow parts as they finish.
The pattern is straightforward: wrap slow components in a Suspense boundary with a fallback UI.
'use client';
import { Suspense } from 'react';
function DashboardPage() {
return (
<div className="dashboard">
<Header />
<Sidebar />
<div className="main-content">
<Suspense fallback={<RevenueSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<ConversionSkeleton />}>
<ConversionMetrics />
</Suspense>
<Suspense fallback={<UserTrendsSkeleton />}>
<UserTrends />
</Suspense>
</div>
</div>
);
}
export default DashboardPage;Now, each widget can load on its own schedule. The revenue chart might take 2 seconds, conversion metrics 5 seconds, and user trends 3 seconds. Instead of waiting 5 seconds to show anything, you show the header and sidebar instantly, then stream each widget as it's ready. Time to interactive drops from 5 seconds to under 500ms.
Under the hood, Next.js with streaming: true in your next.config.js sends HTML chunks to the browser as React Server Components resolve. The browser renders each chunk incrementally, creating a much smoother user experience.
Skeletons That Match: Fallbacks With the Same Dimensions Prevent Layout Shift
A bad fallback ruins everything. If your skeleton doesn't match the final content's dimensions, the page jumps around as each widget loads. Users see boxes slide, text reflow, and buttons move. This isn't just ugly—it breaks usability and tanks your Core Web Vitals score.
Skeletons must have the exact same height, width, and spacing as the real component.
// components/RevenueChart.tsx
async function RevenueChart() {
const data = await fetchRevenueData();
return (
<div className="revenue-card">
<h3>Revenue (30 days)</h3>
<svg width="100%" height="300">
{/* Chart render */}
</svg>
<div className="chart-footer">
<p>Total: ${data.total}</p>
</div>
</div>
);
}
// components/RevenueSkeleton.tsx
function RevenueSkeleton() {
return (
<div className="revenue-card">
<h3>
<div className="skeleton skeleton-text" />
</h3>
<svg width="100%" height="300">
<rect className="skeleton" x="0" y="0" width="100%" height="300" />
</svg>
<div className="chart-footer">
<p>
<div className="skeleton skeleton-text" style={{ width: '150px' }} />
</p>
</div>
</div>
);
}
// CSS
const skeletonStyles = `
.revenue-card {
padding: 16px;
border-radius: 8px;
background: white;
}
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`;
The skeleton div has the same structure and dimensions as RevenueChart. When data arrives, the skeleton swaps out cleanly—no reflow, no jumps. This keeps Cumulative Layout Shift (CLS) close to zero.
We've found that matching skeletons reduce perceived load time by 30–40%. Users see the layout instantly and feel the page is responsive, even if the data takes a few seconds.
Great for Dashboards: Each Widget Loads Independently
Dashboards are where streaming really shines. In a typical admin panel, you have 8–12 widgets: revenue, user trends, conversion funnels, support tickets, inventory levels, etc. Each has its own data source and query time.
Without streaming, the slowest query (often inventory or support data hitting a third-party API) blocks everything. With Suspense, fast queries (revenue, cached metrics) render in 200ms, while slow ones trickle in over the next 5 seconds.
// app/admin/dashboard/page.tsx
import { Suspense } from 'react';
import RevenueWidget from './widgets/RevenueWidget';
import UserTrendsWidget from './widgets/UserTrendsWidget';
import SupportTicketsWidget from './widgets/SupportTicketsWidget';
import InventoryWidget from './widgets/InventoryWidget';
// Skeletons
import { RevenueSkeleton, UserTrendsSkeleton, SupportSkeleton, InventorySkeleton } from './skeletons';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export default function AdminDashboard() {
return (
<div className="dashboard-grid">
<div className="widget-row">
<Suspense fallback={<RevenueSkeleton />}>
<RevenueWidget />
</Suspense>
<Suspense fallback={<UserTrendsSkeleton />}>
<UserTrendsWidget />
</Suspense>
</div>
<div className="widget-row">
<Suspense fallback={<SupportSkeleton />}>
<SupportTicketsWidget />
</Suspense>
<Suspense fallback={<InventorySkeleton />}>
<InventoryWidget />
</Suspense>
</div>
</div>
);
}
When a user loads the dashboard:
- The page header and layout render instantly (no async data).
- Revenue (cached query, ~200ms) appears first.
- User trends (database query, ~800ms) streams in next.
- Support tickets (API call, ~3s) and inventory (slow third-party integration, ~5s) fill in as they arrive.
The user sees a fully-laid-out dashboard in under 500ms and gradually sees data populate. This feels fast, even though the full data takes 5 seconds. Time to interactive (TTI) improves from 5+ seconds to under 1 second.
In production ride-hailing and travel platforms, we've cut dashboard load times by 60–70% by splitting widgets into separate Suspense boundaries. Dashboards with 30+ widgets now feel snappy instead of sluggish.
Common Mistakes
Mistake 1: Suspense boundaries that are too high up the tree. Wrapping your entire page in a single Suspense boundary defeats the purpose. Every widget waits for the slowest one. Keep boundaries tight—one per slow component or widget.
Mistake 2: Fallbacks that don't match the real component size. A skeleton that's too short or too tall causes layout shift. Measure your component and replicate its dimensions exactly in the skeleton.
Mistake 3: Forgetting to set dynamic: 'force-dynamic' on dashboards. If you don't explicitly opt out of static generation, Next.js may cache your page and you'll never see streaming. Always use force-dynamic for real-time dashboards and revalidate: 0 for routes that need fresh data.
Mistake 4: Mixing Server Components and Client Components incorrectly. Mark async data-fetching components with the default (Server Component). Only use 'use client' when you need interactivity. Async components can't be Client Components, so wrapping them wrong breaks streaming.
Mistake 5: Not testing streaming in production. Streaming works beautifully on localhost with fast hardware. Test on a slow 4G connection or throttled network to see the real user experience. You might discover a widget's skeleton is too slow or needs optimization.
Key Takeaways
- Streaming with Suspense lets you render and send the page instantly, then fill in slow data as it arrives—dramatically cutting time-to-interactive.
- Wrap slow components in tight Suspense boundaries with matching skeletons to prevent layout shift and improve perceived performance.
- Dashboards benefit most: each widget loads independently, so slow queries don't block fast ones.
- Always set
dynamic: 'force-dynamic'andrevalidate: 0on real-time pages to ensure streaming actually happens. - Test streaming on slow networks—that's where the real win shows up.
- Skeletons must match the final component's exact layout (height, width, spacing) to maintain zero layout shift.






Comments
Loading comments…