Streaming SSR in .NET 8: Faster First Paint with Placeholders
7 min read
Intermediate
The Performance Win
Streaming SSR delivers your page shell to the browser instantly, even while slow database queries or API calls still run. Users see your navigation, header, and layout within milliseconds, then watch content appear as it becomes ready. This approach cuts perceived load time dramatically compared to waiting for everything before sending any HTML.
You'll compare a traditional SSR page that waits 2 seconds before showing anything against a streaming version that shows structure in 50ms and fills in data as it arrives. The trade-off involves slightly more complexity in your component code, but the user experience improvement often justifies the effort for data-heavy pages.
Expect to learn when streaming helps, when it doesn't, and how to handle placeholders, errors, and partial content gracefully. By the end, you'll know exactly which pages benefit from streaming and how to implement the pattern without breaking existing functionality.
How Streaming Rendering Works
Traditional SSR waits for all async operations to complete before sending HTML to the browser. If you load user data, recent orders, and recommendations in OnInitializedAsync, the server holds the response until every query finishes. Streaming SSR breaks this pattern by sending the page shell immediately, then pushing content updates as data arrives.
The server renders your component tree and sends any content that's ready. When it encounters a component waiting for data, it streams a placeholder instead. As each async operation completes, the server streams the real content as an HTML fragment, and JavaScript swaps it into the page. The browser displays updates progressively without waiting for everything.
This relies on HTTP chunked transfer encoding, a standard feature supported by all modern browsers. The connection stays open, the server sends chunks of HTML as they're ready, and the browser renders each chunk immediately. No special client-side framework needed beyond the small Blazor streaming JavaScript that handles placeholder replacement.
Pages/Dashboard.razor (Traditional SSR)
@page "/dashboard"
@inject IUserService UserService
@inject IOrderService OrderService
<PageTitle>Dashboard</PageTitle>
<h1>Welcome, @user?.Name</h1>
<div class="stats">
<div class="stat-card">
<h3>Recent Orders</h3>
@foreach (var order in recentOrders)
{
<p>@order.Id - @order.Total.ToString("C")</p>
}
</div>
</div>
@code {
private User? user;
private List<Order> recentOrders = [];
protected override async Task OnInitializedAsync()
{
// Server waits for BOTH calls before sending any HTML
user = await UserService.GetCurrentUserAsync();
recentOrders = await OrderService.GetRecentAsync(user.Id);
// If each takes 1 second, user waits 2 seconds to see anything
}
}
This dashboard waits for both data calls to finish before rendering any HTML. If the user service takes 500ms and orders take 1500ms, the browser shows nothing for 2 full seconds. Streaming SSR improves this by sending the page structure immediately.
Converting to Streaming SSR
Blazor enables streaming SSR automatically when you use the @attribute [StreamRendering(true)] directive. Components marked for streaming render twice: once synchronously with placeholder content, then again asynchronously with real data. You control what shows during each phase.
Split your data loading into two methods: one that runs synchronously returning null or empty data, and OnInitializedAsync for the real data. The first render uses the placeholder data, the second render streams in when async operations complete. This pattern requires thinking about what users see before data arrives.
Pages/StreamingDashboard.razor
@page "/streaming-dashboard"
@attribute [StreamRendering(true)]
@inject IUserService UserService
@inject IOrderService OrderService
<PageTitle>Dashboard</PageTitle>
<h1>Welcome, @(user?.Name ?? "Loading...")</h1>
<div class="stats">
<div class="stat-card">
<h3>Recent Orders</h3>
@if (recentOrders.Any())
{
@foreach (var order in recentOrders)
{
<p>@order.Id - @order.Total.ToString("C")</p>
}
}
else
{
<div class="skeleton-loader">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
}
</div>
</div>
@code {
private User? user;
private List<Order> recentOrders = [];
protected override async Task OnInitializedAsync()
{
// First render happens immediately with empty data
// Browser receives and displays the page shell right away
// Then async calls run and stream in when ready
user = await UserService.GetCurrentUserAsync();
recentOrders = await OrderService.GetRecentAsync(user.Id);
// Second render streams to browser, replacing placeholders
}
}
The browser now receives HTML in under 50ms showing the header and skeleton loaders. As each data call completes, Blazor streams the updated content. Users see progress instead of staring at a blank screen, dramatically improving perceived performance even though total load time stays the same.
Designing Effective Placeholders
Good placeholders match the shape and layout of the real content. Skeleton screens that mimic your final UI look more polished than generic spinners. Users perceive pages with skeleton loaders as loading faster than identical pages with loading text, even when actual timing matches.
Create reusable placeholder components for common patterns like lists, cards, or tables. Style them with subtle animations to signal activity without being distracting. Avoid placeholders that jump or resize when real content streams in, this creates jarring layout shifts that harm user experience.
Components/ProductListPlaceholder.razor
@* Reusable skeleton loader for product lists *@
<div class="product-list-placeholder">
@for (int i = 0; i < Count; i++)
{
<div class="product-card-skeleton">
<div class="skeleton-image"></div>
<div class="skeleton-title"></div>
<div class="skeleton-price"></div>
<div class="skeleton-button"></div>
</div>
}
</div>
@code {
[Parameter]
public int Count { get; set; } = 3;
}
The placeholder component creates a grid of skeleton cards matching the final product layout. When products stream in, the content swaps smoothly without layout shift because the placeholder reserves the correct space. This attention to visual continuity makes streaming feel seamless rather than jumpy.
Handling Streaming Errors
When data loading fails during streaming, you need to replace placeholders with error states instead of leaving them forever. Wrap your async calls in try-catch blocks and set error flags that your render logic checks. Users should see helpful error messages rather than eternal loading spinners.
Consider partial success scenarios where some data loads but other parts fail. Your page might successfully show user info but fail loading recent orders. Design your UI to handle these gracefully, showing available data and error states side by side.
This version handles failures gracefully by showing error messages and providing retry buttons. Users understand what went wrong and can take action instead of wondering why content never appears. The page remains functional even when some sections fail to load.
Performance Considerations
Streaming SSR improves perceived performance by reducing time to first contentful paint, but it doesn't reduce total data loading time. If your query takes 2 seconds, users still wait 2 seconds for data whether you stream or not. The benefit comes from showing page structure immediately instead of a blank screen.
Measure first contentful paint (FCP) and largest contentful paint (LCP) metrics with browser dev tools. Streaming typically improves FCP from 2-3 seconds down to 50-200ms on data-heavy pages. LCP improves less dramatically because it measures when the main content finishes, which depends on your slowest data source.
Consider the trade-off between streaming complexity and actual performance gains. Pages that load completely in under 300ms gain little from streaming but add code complexity. Focus streaming on pages with unavoidable slow data sources like complex database queries, external API calls, or file processing.
Watch out for over-streaming. Each streamed section adds a small amount of JavaScript coordination overhead. If you stream 20 tiny sections, the coordination cost might exceed the benefit. Group related slow-loading content into single streamed components rather than creating dozens of individual streaming pieces.
Try It Yourself
Build a comparison page showing traditional SSR versus streaming SSR with artificial delays to see the difference clearly.
Steps
Initialize project: dotnet new blazor -n StreamingDemo
The traditional page shows nothing for 2 seconds, then displays everything at once. The streaming page displays the header and "Loading data..." message in under 100ms, then replaces the loading message with real data after 2 seconds. Users perceive the streaming version as significantly faster even though both take the same total time to fully load.
Performance at Scale
Streaming SSR shines when you have pages with multiple independent data sources that load at different speeds. A dashboard might load user info in 100ms, notifications in 500ms, and analytics in 2 seconds. Streaming lets each section appear as soon as its data arrives instead of waiting for the slowest piece.
Monitor your server resources when enabling streaming. Each streaming response keeps a connection open longer, which can impact server memory under high load. Modern ASP.NET Core handles this efficiently, but track open connections and memory usage as you scale. Most applications won't hit limits, but high-traffic sites should load test streaming pages.
Consider caching strategies for frequently accessed data. If your slow query results change rarely, cache them and serve from memory. Streaming helps with unavoidable slow operations, but the fastest query is the one you don't run. Combine streaming with smart caching for the best overall performance.
Use streaming selectively on pages where it matters most. Your homepage, product listings, and dashboards benefit from instant visual feedback. Simple pages or admin tools that load quickly might not justify the added complexity. Profile your pages and stream only those where time to first content exceeds 200-300ms.
Quick FAQ
Does streaming SSR work with all browsers?
Yes, streaming SSR relies on standard HTTP chunked transfer encoding supported by all modern browsers. Older browsers that don't support streaming will wait for the complete response before rendering, falling back to traditional SSR behavior without breaking functionality.
When should I use streaming over regular SSR?
Use streaming when parts of your page load slowly (database queries, API calls) but you want users to see content immediately. If your entire page loads quickly (under 200ms), streaming adds complexity without clear benefits. Measure your data loading times and stream only sections that take longer than 100-150ms.
Can I stream multiple sections simultaneously?
Absolutely. Multiple components can stream independently, each completing when its data arrives. The browser updates each section as it streams in. This works great for dashboards or pages with several data sources that load at different speeds.
What happens if a streamed component fails?
Wrap streamed content in error boundaries or try-catch blocks. If data loading fails, stream an error state instead of leaving placeholders forever. The streaming mechanism itself is reliable, but your data sources might fail, so handle errors explicitly in your component code.
Does streaming affect SEO or crawlers?
Search engine crawlers typically wait for the complete response before indexing, so they see the fully rendered page with streamed content. Streaming improves user experience without harming SEO. Test with Google's Rich Results Test tool to verify your streamed pages render correctly for crawlers.