Still rendering lists like it’s 2015? Try scrolling 100,000 rows without a noisy fan, dropped frames, or angry users. In this post I’ll show you how I ship smooth lists in Blazor with the built‑in Virtualize
component: variable-size rows, infinite scroll, streamed data, placeholders, and clean paging with Entity Framework. I’ll also share simple benchmarks from a real project.
What you’ll build and why it matters
Long lists kill performance. A naïve foreach
over a huge collection forces the browser to create and layout thousands of DOM nodes. That means long time to first paint, janky scroll, and high memory.
Blazor’s Virtualize
renders only what the user can see (plus a tiny buffer). The rest is just space. When the user scrolls, Virtualize
asks for the next slice of items. Result: fast render, low memory, happy users.
Mental model: think of a theater with one stage.
Virtualize
keeps a few actors on stage and swaps them as the scene moves. The whole cast exists somewhere (DB or API), but the browser only meets a handful at once.
Quick start: virtualize 100,000 in memory
Let’s get a feel for it with an in‑memory list.
Page: Pages/VirtualizeDemo.razor
@page "/virtualize-demo"
<PageTitle>Virtualize Demo</PageTitle>
<h3>Virtualize 100,000 items</h3>
<Virtualize Items="items" ItemContent="RowTemplate" OverscanCount="3" ItemSize="34" Placeholder="PlaceholderRow">
</Virtualize>
@code {
private List<Item> items = new();
protected override void OnInitialized()
{
// 100k items
items = Enumerable.Range(1, 100_000)
.Select(i => new Item { Id = i, Title = $"Row {i}", Notes = i % 10 == 0 ? $"note {i}" : string.Empty })
.ToList();
}
private RenderFragment<Item> RowTemplate => item => builder =>
{
var seq = 0;
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", "row");
builder.OpenElement(seq++, "strong");
builder.AddContent(seq++, item.Title);
builder.CloseElement();
if (!string.IsNullOrEmpty(item.Notes))
{
builder.OpenElement(seq++, "span");
builder.AddAttribute(seq++, "class", "muted");
builder.AddContent(seq++, $" — {item.Notes}");
builder.CloseElement();
}
builder.CloseElement();
};
private RenderFragment PlaceholderRow => builder =>
{
var seq = 0;
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", "row skeleton");
builder.AddContent(seq++, "\u00A0");
builder.CloseElement();
};
private sealed class Item
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string? Notes { get; set; }
}
}
Styles (optional): add to wwwroot/css/site.css
.row { height: 34px; display:flex; align-items:center; padding:0 12px; border-bottom:1px solid #eee; }
.muted { color:#666; font-size:12px; margin-left:6px; }
.skeleton { background: linear-gradient(90deg,#f2f2f2 25%,#e6e6e6 50%,#f2f2f2 75%); background-size: 400% 100%; animation: sk 1.2s ease-in-out infinite; }
@keyframes sk { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }
That’s already smooth. The key bits:
ItemSize
is an estimate (px). It helpsVirtualize
calculate spacer sizes.OverscanCount
pre-renders a few extra items above/below to avoid white gaps while scrolling.Placeholder
shows while a batch is on the way.
Real data: ItemsProvider
+ server paging
In real apps you won’t load 100k into memory on the client. Use ItemsProvider
so Virtualize
can ask for a slice: start index + count. On the server, use Skip/Take with a stable OrderBy
.
Server: ASP.NET Core + EF Core
Entity
public sealed class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
DbContext
public sealed class AppDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
}
Minimal API
app.MapGet("/api/products", async (
[AsParameters] PageRequest req,
AppDbContext db,
CancellationToken ct) =>
{
// Always order for stable paging
var query = db.Products.AsNoTracking().OrderBy(p => p.Id);
var total = await query.CountAsync(ct);
var items = await query
.Skip(req.StartIndex)
.Take(req.Count)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync(ct);
return Results.Ok(new PageResponse<ProductDto>(items, total));
});
public sealed record PageRequest(int StartIndex = 0, int Count = 20);
public sealed record PageResponse<T>(IReadOnlyList<T> Items, int TotalItemCount);
public sealed record ProductDto(int Id, string Name, decimal Price);
Client: Blazor ItemsProvider
@page "/products"
<h3>Products (virtualized)</h3>
<Virtualize ItemsProvider="LoadProducts" ItemContent="ProductRow" Placeholder="PlaceholderRow" OverscanCount="3" />
@code {
private readonly HttpClient _http = new() { BaseAddress = new Uri("https://localhost:5001") };
private async ValueTask<ItemsProviderResult<ProductDto>> LoadProducts(ItemsProviderRequest req)
{
// Map Blazor’s request to API query
var url = $"/api/products?StartIndex={req.StartIndex}&Count={req.Count}";
var resp = await _http.GetFromJsonAsync<PageResponse<ProductDto>>(url, req.CancellationToken);
if (resp is null)
return new ItemsProviderResult<ProductDto>(Array.Empty<ProductDto>(), 0);
return new ItemsProviderResult<ProductDto>(resp.Items, resp.TotalItemCount);
}
private RenderFragment<ProductDto> ProductRow => p => builder =>
{
var i = 0;
builder.OpenElement(i++, "div");
builder.AddAttribute(i++, "class", "row");
builder.AddContent(i++, $"#{p.Id} {p.Name} – {p.Price:C}");
builder.CloseElement();
};
private RenderFragment PlaceholderRow => builder =>
{
var i = 0;
builder.OpenElement(i++, "div");
builder.AddAttribute(i++, "class", "row skeleton");
builder.CloseElement();
};
private sealed record PageResponse<T>(IReadOnlyList<T> Items, int TotalItemCount);
private sealed record ProductDto(int Id, string Name, decimal Price);
}
Notes from production:
- Always
OrderBy
when usingSkip/Take
. Without it, paging is undefined. - Use
AsNoTracking()
for read‑only lists. - Return
TotalItemCount
so the scrollbar size is right.
Infinite scroll when you don’t know the total yet
Sometimes the total is not known up front (log feeds, search across many shards, etc.). Two simple options:
Option A: pretend the list is huge, then correct later
Return a large TotalItemCount
(e.g., int.MaxValue
) until the data source says “no more”. Then store the true total and trigger RefreshDataAsync
so Virtualize
recalculates.
<Virtualize @ref="_virt" ItemsProvider="LoadUnknown" ItemContent="Row" />
@code {
private Virtualize<ProductDto>? _virt;
private int? _knownTotal;
private async ValueTask<ItemsProviderResult<ProductDto>> LoadUnknown(ItemsProviderRequest req)
{
var page = await _http.GetFromJsonAsync<PageResponse<ProductDto>>($"/api/search?Skip={req.StartIndex}&Take={req.Count}");
if (page is null) return new(Array.Empty<ProductDto>(), 0);
if (page.IsComplete && _knownTotal is null)
{
_knownTotal = page.Total;
// Ask Virtualize to use the new total
_ = InvokeAsync(async () => await _virt!.RefreshDataAsync());
}
var total = _knownTotal ?? int.MaxValue;
return new ItemsProviderResult<ProductDto>(page.Items, total);
}
}
Option B: live append with Items
Bind Virtualize
to a growing list and push new items as they arrive (SignalR, gRPC stream, IAsyncEnumerable, etc.). Virtualize
will use the current count.
<Virtualize Items="_feed" ItemContent="Row" OverscanCount="2" />
@code {
private readonly List<LogDto> _feed = new();
protected override async Task OnInitializedAsync()
{
await foreach (var item in _client.StreamLogsAsync())
{
_feed.Add(item);
StateHasChanged(); // paint chunk
}
}
}
Server stream (minimal API + fake source):
app.MapGet("/api/logs/stream", async IAsyncEnumerable<LogDto> (CancellationToken ct) =>
{
// Stream a growing JSON array
for (var i = 0; i < 100_000; i++)
{
yield return new LogDto(i, $"log {i}", DateTimeOffset.UtcNow);
await Task.Delay(5, ct); // simulate source
}
});
Client reader using JsonSerializer.DeserializeAsyncEnumerable
:
public async IAsyncEnumerable<LogDto> StreamLogsAsync([EnumeratorCancellation] CancellationToken ct = default)
{
using var resp = await _http.GetAsync("/api/logs/stream", HttpCompletionOption.ResponseHeadersRead, ct);
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<LogDto>(await resp.Content.ReadAsStreamAsync(ct), cancellationToken: ct))
{
if (item is not null) yield return item;
}
}
This is great for a live feed. When the app adds rows to _feed
, the list keeps scrolling and stays smooth because only a small part is rendered.
Variable‑size rows that still scroll smoothly
Virtualize
assumes a constant average height. Rows can vary, but large swings may cause small jumps. Three tips that worked for me:
- Set a decent
ItemSize
. UsegetBoundingClientRect
in dev tools to measure 20-30 rows and pick the average. - Add min height so very short rows don’t break the estimate:
.row { min-height: 34px; } .row.large { min-height: 64px; }
- Avoid heavy content in row templates. Complex grids inside each row multiply layout work.
Example with mixed sizes:
<Virtualize Items="items" ItemSize="40" ItemContent="Row" />
@code {
private RenderFragment<Item> Row => item => b =>
{
var i = 0;
b.OpenElement(i++, "div");
b.AddAttribute(i++, "class", item.Id % 7 == 0 ? "row large" : "row");
b.AddContent(i++, item.Title);
if (item.Id % 7 == 0)
{
b.OpenElement(i++, "div");
b.AddContent(i++, new string('•', 80)); // fake tall content
b.CloseElement();
}
b.CloseElement();
};
}
If you need perfect layout with very different heights, you can measure real heights via JS interop and update an average ItemSize
or switch to a masonry layout for that section. For most business lists, a stable average works fine.
Placeholder content that feels fast
Show a skeleton while data loads. This sets user expectation and hides reflow.
<Virtualize ItemsProvider="LoadProducts" Placeholder="PlaceholderRow" />
@code {
private RenderFragment PlaceholderRow => b =>
{
var i = 0;
b.OpenElement(i++, "div");
b.AddAttribute(i++, "class", "row skeleton");
b.OpenElement(i++, "span");
b.AddAttribute(i++, "class", "dot");
b.CloseElement();
b.CloseElement();
};
}
.dot { width:12px; height:12px; border-radius:50%; background:#ddd; display:inline-block; margin-right:8px; }
Keep placeholders cheap: one or two simple shapes, no images, no deep child trees.
Performance: simple numbers from a real app
Hardware: i7 laptop, 32 GB RAM, Edge, Release build, Blazor WebAssembly.
Method: measured with the browser’s performance tab and log timestamps. Your results will differ.
Scenario | Nodes in DOM | First render | Scroll CPU | Memory |
---|---|---|---|---|
Naïve foreach of 100k | ~100,000 | 5.2 s | spikes to 80–100% | ~480 MB |
Virtualize + Items | ~60–120 | 130 ms | stays under 20–30% | ~45 MB |
Virtualize + ItemsProvider (API) | ~60–120 | 180 ms (network bound) | under 25% | ~48 MB |
The big win is obvious: tiny DOM + fast paint. With paging over the network, the bottleneck moves to the API, which we can tune.
EF Core paging: things that save hours later
- Index the order column. If you order by
Id
, ensureId
is indexed (PK already is). For other sort fields, add a non‑clustered index. - Stable sort: never page on an unsorted set. If users can change sort, pass the sort to the API.
- Project to a slim DTO. Don’t send big blobs you don’t need in the list.
- Cancellation: pass
req.CancellationToken
into EF calls so a fast scroll cancels the old request. - AsNoTracking: read‑only lists don’t need change tracking.
- Cache: if the list barely changes, cache pages on the server for a few seconds.
Example with dynamic sort and filters:
app.MapGet("/api/products/query", async (
int startIndex, int count, string? sort = null, string? term = null,
AppDbContext db, CancellationToken ct) =>
{
IQueryable<Product> q = db.Products.AsNoTracking();
if (!string.IsNullOrWhiteSpace(term))
{
var t = term.Trim();
q = q.Where(p => EF.Functions.ILike(p.Name, $"%{t}%")); // PostgreSQL sample
}
q = sort switch
{
"name" => q.OrderBy(p => p.Name).ThenBy(p => p.Id),
"price" => q.OrderBy(p => p.Price).ThenBy(p => p.Id),
_ => q.OrderBy(p => p.Id)
};
var total = await q.CountAsync(ct);
var items = await q.Skip(startIndex).Take(count)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync(ct);
return new PageResponse<ProductDto>(items, total);
});
Smooth UX: small details that add up
- Keyboard and mouse: keep rows focusable and use simple hover/focus styles.
- Row keys: when using components inside the row, set
@key item.Id
to improve diffing. - Overscan: start with
2-4
. More overscan increases DOM size; less can show gaps during fast scroll. - Throttle live updates: when appending to
Items
during a stream, batch updates every 50-100 ms. - Fixed height container: place the list inside a container with a defined height (
height: 70vh; overflow:auto;
) if the page around it is heavy.
Example container:
<div style="height:70vh; overflow:auto; border:1px solid #ddd;">
<Virtualize ItemsProvider="LoadProducts" />
</div>
Common mistakes (I made them so you don’t have to)
- Recreating the provider on each render. Keep the delegate stable; don’t capture changing state unless needed.
- Mutating the backing list without notifying the UI. If using
Items
, callStateHasChanged
after batch changes. - Missing
OrderBy
withSkip/Take
. That one breaks paging as soon as new data is inserted. - Bloated row templates: images, charts, or heavy components inside each row will slow everything. Keep rows lean; open a detail view on click.
- Guessing
ItemSize
wrong: measure once, then tweak.
End‑to‑end sample: search with infinite scroll + placeholders
Client page
@page "/search"
@inject HttpClient Http
<input @bind="_term" @bind:event="oninput" placeholder="Search..." />
<div style="height:70vh; overflow:auto; border:1px solid #ddd; margin-top:8px;">
<Virtualize ItemsProvider="Search" ItemContent="Row" Placeholder="PlaceholderRow" OverscanCount="3" />
</div>
@code {
private string _term = string.Empty;
private async ValueTask<ItemsProviderResult<SearchResult>> Search(ItemsProviderRequest req)
{
var url = $"/api/search?term={Uri.EscapeDataString(_term)}&startIndex={req.StartIndex}&count={req.Count}";
var page = await Http.GetFromJsonAsync<PageResponse<SearchResult>>(url, req.CancellationToken);
page ??= new PageResponse<SearchResult>(Array.Empty<SearchResult>(), 0);
return new ItemsProviderResult<SearchResult>(page.Items, page.TotalItemCount);
}
private RenderFragment<SearchResult> Row => r => b =>
{
var i = 0;
b.OpenElement(i++, "div"); b.AddAttribute(i++, "class", "row");
b.OpenElement(i++, "strong"); b.AddContent(i++, r.Title); b.CloseElement();
b.OpenElement(i++, "span"); b.AddAttribute(i++, "class", "muted"); b.AddContent(i++, $" — score {r.Score}"); b.CloseElement();
b.CloseElement();
};
private RenderFragment PlaceholderRow => b => { var i = 0; b.OpenElement(i++, "div"); b.AddAttribute(i++, "class", "row skeleton"); b.CloseElement(); };
private sealed record SearchResult(int Id, string Title, double Score);
private sealed record PageResponse<T>(IReadOnlyList<T> Items, int TotalItemCount);
}
Server
app.MapGet("/api/search", async (string term, int startIndex, int count, AppDbContext db, CancellationToken ct) =>
{
var q = db.Products.AsNoTracking()
.Where(p => EF.Functions.ILike(p.Name, $"%{term}%"))
.OrderByDescending(p => p.Price) // fake relevance
.ThenBy(p => p.Id);
var total = await q.CountAsync(ct);
var items = await q.Skip(startIndex).Take(count)
.Select(p => new SearchResult(p.Id, p.Name, (double)p.Price))
.ToListAsync(ct);
return new PageResponse<SearchResult>(items, total);
});
This is a strong base for search, audit logs, catalogs, and any list you need to scroll for miles.
FAQ: Virtualize in the real world
Virtualize
work in Blazor Server and WebAssembly?Yes. In Server, updates travel over SignalR, but the rendering model is the same.
Yes, within reason. Use a good average ItemSize
and keep huge rows rare. For extreme cases, consider a separate detail pane.
Keep a @ref
to the component and call await _virt.RefreshDataAsync()
.
Start with viewport rows × 2. For a 70vh list with 34px rows, that’s ~40-50 items per page.
Increase OverscanCount
a bit (3-6). Also keep placeholder simple.
Yes, but sticky group headers count as rows. Precompute group markers and render them as light rows.
Use the Performance tab, track DOMContentLoaded
, and watch layout time while dragging the scrollbar.
Conclusion: fast lists without sweat
With Virtualize
you can scroll 100k+ rows, page cleanly from EF Core, show a nice skeleton, and keep CPU low even on mid‑range laptops. Start with a good average ItemSize
, keep rows lean, and let the API serve small pages. If you need live data, stream items and bind Items
so the list grows smoothly.
I’d love to hear your case: what list are you going to speed up first, and what blocked you so far? Leave a comment and let’s fix it together.