Making Fast Pages Even Faster
If you've ever watched your SSR page take 500ms to render because it's waiting for a slow database query, you know the pain. Static server rendering should be fast, but unoptimized data access can sabotage that speed. Your page sits idle while Entity Framework joins six tables or your API client times out waiting for an external service.
The good news is that Blazor SSR gives you tools to fix these bottlenecks. Streaming rendering shows page structure immediately while slow data loads in the background. Response caching serves pages from memory instead of re-rendering. Smart data loading patterns prevent unnecessary queries and reduce database load.
You'll apply five practical optimizations that deliver measurable speed improvements. Each technique targets a specific performance problem with clear before-and-after results. By the end, you'll know how to diagnose slow pages and apply the right optimization technique.
Response Caching for Static Content
Pages that show the same content to all users don't need to re-render on every request. Product listings, blog posts, and documentation can be rendered once and cached in memory. Subsequent requests get the cached HTML instantly without executing your component code or querying databases.
.NET 8's output caching middleware makes this trivial. Add the OutputCache attribute to your page component, and the framework caches the entire HTTP response. Cache duration can be based on time, tags, or custom policies depending on how often your content changes.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents();
// Add output caching
builder.Services.AddOutputCache(options =>
{
// Default policy: cache for 60 seconds
options.AddBasePolicy(builder =>
builder.Expire(TimeSpan.FromSeconds(60)));
// Named policy for longer caching
options.AddPolicy("Long", builder =>
builder.Expire(TimeSpan.FromMinutes(30)));
});
var app = builder.Build();
app.UseOutputCache(); // Enable caching middleware
app.MapRazorComponents<App>();
app.Run();
@page "/products"
@attribute [OutputCache(PolicyName = "Long")]
@inject IProductService Products
<PageTitle>Products</PageTitle>
<h1>Product Catalog</h1>
<div class="product-grid">
@foreach (var product in products)
{
<div class="product-card">
<h3>@product.Name</h3>
<p>@product.Price.ToString("C")</p>
</div>
}
</div>
@code {
private List<Product> products = new();
protected override async Task OnInitializedAsync()
{
// This query only runs when cache expires
products = await Products.GetAllAsync();
}
}
The first request executes the component, queries the database, and caches the response for 30 minutes. The next 1000 requests hit the cache and return in under 5ms each. Your database gets queried once per 30 minutes instead of thousands of times, dramatically reducing load.
Streaming Rendering for Slow Data
When your page loads data from multiple sources with different speeds, streaming rendering prevents fast sections from waiting on slow ones. The page renders with placeholders for slow sections, streams the response to the browser immediately, then updates placeholders as data arrives.
Enable streaming by adding the StreamRendering attribute. The component renders twice: first with null data showing loading placeholders, then again with actual data that streams to the already-displayed page. Users see your page structure within 50ms even if database queries take 2 seconds.
@page "/dashboard"
@attribute [StreamRendering(true)]
@inject IAnalyticsService Analytics
@inject IReportService Reports
<PageTitle>Dashboard</PageTitle>
<!-- Renders immediately with page structure -->
<h1>Analytics Dashboard</h1>
<div class="dashboard-layout">
<!-- Fast section: renders in first pass -->
<section class="sidebar">
<h2>Quick Links</h2>
<nav>
<a href="/reports">Reports</a>
<a href="/settings">Settings</a>
</nav>
</section>
<!-- Slow section: streams after data loads -->
<section class="main-content">
@if (stats == null)
{
<p>Loading statistics...</p>
}
else
{
<div class="stats-grid">
<div class="stat">
<h3>Total Revenue</h3>
<p>@stats.Revenue.ToString("C")</p>
</div>
<div class="stat">
<h3>Active Users</h3>
<p>@stats.ActiveUsers.ToString("N0")</p>
</div>
<div class="stat">
<h3>Conversion Rate</h3>
<p>@stats.ConversionRate.ToString("P2")</p>
</div>
</div>
}
</section>
</div>
@code {
private AnalyticsStats? stats;
protected override async Task OnInitializedAsync()
{
// Slow query (2-3 seconds)
stats = await Analytics.GetCurrentStatsAsync();
}
}
Users see the heading, sidebar, and "Loading statistics..." message within 50ms. The browser displays this partial page immediately while the server continues working. After 2 seconds when the stats load, Blazor streams the updated HTML to replace the loading message. This perceived performance boost is huge compared to showing a blank page for 2 seconds.
In-Memory Data Caching
Cache expensive database queries or API calls that return the same data for multiple requests. Product catalogs, category lists, and configuration data change infrequently but get queried constantly. Loading them once and caching for 5-30 minutes eliminates thousands of redundant database hits.
Use IMemoryCache to store query results in server memory. Check the cache first before querying your database. Set appropriate expiration times based on how often the data changes. This simple pattern can reduce database load by 90% for read-heavy applications.
public class CachedProductService : IProductService
{
private readonly ApplicationDbContext _db;
private readonly IMemoryCache _cache;
private const string AllProductsCacheKey = "products_all";
public CachedProductService(
ApplicationDbContext db,
IMemoryCache cache)
{
_db = db;
_cache = cache;
}
public async Task<List<Product>> GetAllAsync()
{
// Try to get from cache first
if (_cache.TryGetValue(AllProductsCacheKey, out List<Product>? cached))
{
return cached!;
}
// Cache miss: query database
var products = await _db.Products
.Where(p => p.IsActive)
.OrderBy(p => p.Name)
.ToListAsync();
// Store in cache for 10 minutes
_cache.Set(AllProductsCacheKey, products,
new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
return products;
}
public async Task<Product?> GetByIdAsync(int id)
{
var cacheKey = $"product_{id}";
if (_cache.TryGetValue(cacheKey, out Product? cached))
{
return cached;
}
var product = await _db.Products.FindAsync(id);
if (product != null)
{
_cache.Set(cacheKey, product,
TimeSpan.FromMinutes(5));
}
return product;
}
}
The first request queries the database and caches results for 10 minutes. The next few hundred requests pull from cache, returning results in microseconds instead of milliseconds. Your database sees one query every 10 minutes instead of dozens per second, freeing resources for writes and user-specific queries.
Optimizing Entity Framework Queries
Lazy loading and tracking can silently kill SSR performance. Every navigation property access triggers a database query, and change tracking adds overhead you don't need for read-only rendering. Use explicit loading with projections to fetch exactly what you need in one query.
Add AsNoTracking() to queries that only read data for display. Use Select() to project into DTOs instead of loading entire entities. Include related data explicitly with Include() to avoid N+1 queries. These small changes can reduce query time from 200ms to 20ms.
public class ProductService : IProductService
{
private readonly ApplicationDbContext _db;
public ProductService(ApplicationDbContext db)
{
_db = db;
}
// BAD: Tracking + lazy loading + loads full entities
public async Task<List<Product>> GetAllSlowAsync()
{
return await _db.Products.ToListAsync();
// Each Product.Category access = new query (N+1 problem)
// Change tracking overhead even for read-only display
}
// GOOD: No tracking + eager loading + projection
public async Task<List<ProductDto>> GetAllFastAsync()
{
return await _db.Products
.AsNoTracking() // Skip change tracking
.Include(p => p.Category) // Eager load related data
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
CategoryName = p.Category.Name,
ImageUrl = p.ImageUrl
// Only select fields needed for display
})
.ToListAsync();
}
// For detail pages: explicit includes
public async Task<ProductDetailDto?> GetDetailByIdAsync(int id)
{
return await _db.Products
.AsNoTracking()
.Include(p => p.Category)
.Include(p => p.Reviews)
.ThenInclude(r => r.User)
.Where(p => p.Id == id)
.Select(p => new ProductDetailDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
CategoryName = p.Category.Name,
Reviews = p.Reviews.Select(r => new ReviewDto
{
Rating = r.Rating,
Comment = r.Comment,
UserName = r.User.UserName
}).ToList()
})
.FirstOrDefaultAsync();
}
}
The optimized queries fetch all needed data in a single database round trip, skip change tracking overhead, and return only the fields required for rendering. This reduces memory usage, speeds up serialization, and cuts query time significantly. For a product listing with categories, this optimization typically reduces load time from 200ms to under 30ms.
Parallel Data Loading
When your page needs data from multiple independent sources, don't load them sequentially. Fire all queries simultaneously and await them together. This parallelization can cut page load time in half when you're fetching from multiple databases, APIs, or services.
Use Task.WhenAll to execute multiple async operations concurrently. The total time becomes the slowest query instead of the sum of all queries. Be careful not to overwhelm your database connection pool, but for most scenarios parallel loading is a free performance win.
@page "/"
@inject IProductService Products
@inject IBlogService Blog
@inject IAnalyticsService Analytics
<PageTitle>Home</PageTitle>
<h1>Welcome</h1>
<div class="home-sections">
<section class="featured-products">
<h2>Featured Products</h2>
@foreach (var product in featuredProducts)
{
<div>@product.Name - @product.Price.ToString("C")</div>
}
</section>
<section class="recent-posts">
<h2>Latest Blog Posts</h2>
@foreach (var post in recentPosts)
{
<div>@post.Title</div>
}
</section>
<section class="stats">
<h2>Quick Stats</h2>
<p>Active Users: @stats.ActiveUsers</p>
</section>
</div>
@code {
private List<Product> featuredProducts = new();
private List<BlogPost> recentPosts = new();
private Stats stats = new();
protected override async Task OnInitializedAsync()
{
// BAD: Sequential loading (300ms + 200ms + 150ms = 650ms)
// featuredProducts = await Products.GetFeaturedAsync();
// recentPosts = await Blog.GetRecentAsync();
// stats = await Analytics.GetStatsAsync();
// GOOD: Parallel loading (max of 300ms, 200ms, 150ms = 300ms)
var productsTask = Products.GetFeaturedAsync();
var postsTask = Blog.GetRecentAsync();
var statsTask = Analytics.GetStatsAsync();
await Task.WhenAll(productsTask, postsTask, statsTask);
featuredProducts = await productsTask;
recentPosts = await postsTask;
stats = await statsTask;
}
}
Three independent queries that would take 650ms sequentially now complete in 300ms because they run in parallel. The page waits only for the slowest query instead of all queries combined. This simple refactoring halves the page load time without changing any business logic.
Measure Your Improvements
Apply caching to see immediate performance gains.
Steps
- Create:
dotnet new blazor -n PerfDemo
- Navigate:
cd PerfDemo
- Add the cached page below
- Start:
dotnet run
- Visit:
https://localhost:5001/cached
- Refresh multiple times and watch response time
- Compare with
/uncached page
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@page "/cached"
@attribute [OutputCache(Duration = 30)]
<PageTitle>Cached Page</PageTitle>
<h1>Cached Product List</h1>
<p>Generated at: @DateTime.Now.ToString("HH:mm:ss.fff")</p>
<p><em>This timestamp won't change for 30 seconds (cached)</em></p>
<div class="products">
@foreach (var i in Enumerable.Range(1, 20))
{
<div style="border: 1px solid #ddd; padding: 10px; margin: 10px;">
<h3>Product @i</h3>
<p>Price: $@(Random.Shared.Next(10, 100)).99</p>
</div>
}
</div>
@* For comparison, create Components/Pages/UncachedDemo.razor without OutputCache *@
@* Same code but without the OutputCache attribute *@
Run result
The cached page shows the same timestamp for 30 seconds across multiple refreshes, indicating it's serving from cache. Browser DevTools shows response time under 5ms. The uncached version regenerates on every refresh, taking 20-50ms. The cached version handles 10x more requests with the same server resources.
Performance Notes
Start by measuring before optimizing. Use browser DevTools Network tab to identify slow pages. Look at Time to First Byte (TTFB) to measure server rendering time. If TTFB exceeds 200ms, you have a server-side performance problem worth fixing.
Apply caching first because it has the biggest impact with the least code. Response caching can serve 1000 requests in the time it takes to render one page. Data caching reduces database load dramatically. Both are simple to implement and deliver measurable results.
Use streaming rendering for pages with slow data sources you can't cache. Dashboards, reports, and analytics pages benefit from showing structure immediately while data loads. This perceived performance improvement keeps users engaged instead of staring at blank screens.
Optimize database queries after caching and streaming are in place. Profile your Entity Framework queries to find N+1 problems. Add AsNoTracking() for read-only queries. Use projections to fetch only needed fields. These micro-optimizations add up when applied consistently across your data layer.