Blazor Virtualize: Rendering Large Lists Without Freezing the UI
A plain @foreach over tens of thousands of rows creates one DOM element per item, freezing the page. Blazor's built-in Virtualize component renders only the visible window, but it requires a determinate scroll container and introduces accessibility trade-offs.
24 May 2026, 23:50 UTC

The page-freeze problem with plain loops
You bind a table to 50,000 records with a simple @foreach loop, and the page stops responding. Scrolling stutters, clicks lag, and the browser tab may show an unresponsive warning. The bottleneck is not Blazor's renderer alone. Every row becomes a DOM element, Blazor's diffing algorithm walks the entire tree on each update, and the browser's layout and paint work grows linearly with the node count. At tens of thousands of rows, the renderer spends more time managing invisible elements than responding to the user.
Render only the visible window
Available since .NET 5, the built-in Virtualize component changes the contract: instead of materializing every item, it tracks the scroll position of a container and the total item count, then renders only the rows inside the viewport plus a small overscan buffer. The scrollbar still represents the full dataset, but the DOM stays bounded to a few dozen nodes.
Virtualize offers two data modes. Use Items when the entire collection is already in memory—Virtualize still prevents DOM bloat. Use ItemsProvider when the dataset is server-backed, unbounded, or expensive to serialize into JSON. The provider is a delegate that receives a VirtualizeQuery describing the requested slice and returns a ValueTask of an ItemsProviderResult containing the page of data and the grand total.
Worked example: paging a database table
Suppose you have an Entity Framework Core DbContext and a Product entity. You can wire ItemsProvider to translate the query into an ordered Skip/Take call. The delegate signature is Func<VirtualizeQuery<Product>, ValueTask<ItemsProviderResult<Product>>>.
@page "/products"
@using Microsoft.EntityFrameworkCore
@inject YourDbContext Db
<div style="height: 600px; overflow-y: auto;">
<Virtualize Context="product" ItemsProvider="LoadProducts">
<ItemContent>
<tr @key="product.Id">
<td>@product.Name</td>
<td>@product.Price.ToString("C")</td>
</tr>
</ItemContent>
<Placeholder>
<tr><td colspan="2">Loading…</td></tr>
</Placeholder>
</Virtualize>
</div>
@code {
private async ValueTask<ItemsProviderResult<Product>> LoadProducts(
VirtualizeQuery<Product> request)
{
var query = Db.Products.AsNoTracking().OrderBy(p => p.Id);
var total = await query.CountAsync();
var items = await query
.Skip(request.StartIndex)
.Take(request.Count)
.ToListAsync();
return new ItemsProviderResult<Product>(items, total);
}
}
Three details matter here. First, the query must be ordered; without a deterministic OrderBy, paging can duplicate or skip rows across requests. Second, the returned total count must be accurate—an incorrect total corrupts the scrollbar and paging math. Third, the @key on the row uses the entity identity, not the loop index, so scroll position maps to a stable record even if the underlying page changes.
Trade-offs you must plan for
Virtualization removes off-screen rows from the DOM. That is the performance win, but it creates real limitations. Browser find-in-page will not match unrendered text. Printing the page may omit rows that are not currently virtualized into view. Screen-reader and keyboard navigation generally reach only the rows that exist in the DOM, so a user tabbing through a 100,000-row grid will encounter a bounded subset, not the full logical list.
Variable-height rows are another trap. If each row can wrap to multiple lines, the component cannot compute the scroll offset accurately unless you pin ItemSize or keep rows uniform. A mismatched estimate causes scroll jumpiness and misalignment while pages load, so placeholder content should match the final row height. Similarly, Virtualize needs a scrollable ancestor with a determinate height. Inside an auto-height flex container or an overflow-hidden parent, it may render nothing because it cannot calculate the visible window.
Finally, do not store UI state that depends on off-screen rows inside the component. Those rows are unmounted and recreated on demand, so per-row edit state, selection flags, or focus must live in your own data model, not in child component state.
How to verify the result
Start by loading a large dataset—100,000 items is a useful stress threshold. Open your browser's DevTools console and run document.querySelectorAll('tr').length while scrolling. If virtualization is working, the count stays bounded to roughly the viewport size plus overscan, rather than growing to 100,000.
Next, log the VirtualizeQuery values inside your provider. Confirm that StartIndex and Count stay within the bounds of the total and that the total you return matches the database count. Then test keyboard navigation: Tab into the grid and use arrow keys to confirm focus moves through rendered rows predictably. Resize the window and toggle device emulation to ensure the scroll container keeps a determinate height and the window recalculates correctly.
If the DOM node count climbs without bound, check that the scroll container has an explicit height and that you have not accidentally nested Virtualize inside an auto-sizing parent.
Bottom line
For in-memory collections, wrap the list in Virtualize with Items. For server-backed or unbounded data, implement ItemsProvider with an ordered skip/take query, an accurate total, and a stable @key. Accept that off-screen rows are absent from the DOM, and design your accessibility and print strategy around that constraint. The payoff is a page that stays responsive whether the table holds 100 rows or 100,000.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.