Virtualisation in Blazor: Rendering Large Lists Efficiently

Rendering 10,000 items in a list might be technically correct, but it's practically unusable. The browser struggles with the DOM size, Blazor's diff algorithm works overtime, and the user sees a frozen page. Virtualisation solves this by rendering only the items currently visible in the viewport, plus a small buffer. Blazor includes a built-in Virtualize component that makes this straightforward.

The Problem

Consider a naive approach to rendering a large list:

razor
@foreach (var item in allItems)
{
    <div class="list-item">
        <span>@item.Name</span>
        <span>@item.Description</span>
    </div>
}

With 10,000 items, this creates 10,000 DOM elements. The initial render is slow, scrolling is janky, and any re-render processes all items even though the user can only see 20 at a time.

Enter Virtualize

Replace the @foreach with Virtualize:

razor
<div style="height: 600px; overflow-y: auto;">
    <Virtualize Items="allItems" Context="item">
        <div class="list-item" style="height: 50px;">
            <span>@item.Name</span>
            <span>@item.Description</span>
        </div>
    </Virtualize>
</div>

Now Blazor only renders the items visible in the 600px container, plus a few above and below for smooth scrolling. As the user scrolls, items are recycled — old ones are removed from the DOM and new ones are added.

Key Requirements

  1. The container must have a fixed height and overflow-y: auto (or scroll).
  2. Each item should have a consistent height. Virtualisation works best with fixed-height items. Variable heights are supported but less performant.

Controlling the Item Size

By default, Virtualize estimates item sizes. For better performance, specify the expected item height:

razor
<Virtualize Items="allItems" Context="item" ItemSize="50">
    <div class="list-item">
        @item.Name
    </div>
</Virtualize>

The ItemSize value (in pixels) helps the component calculate how many items to render and where to position the scroll thumb.

Overscan Count

The OverscanCount parameter controls how many extra items are rendered above and below the visible area. A higher value reduces flickering during fast scrolling but increases DOM size:

razor
<Virtualize Items="allItems" OverscanCount="10" Context="item">
    <div class="list-item">@item.Name</div>
</Virtualize>

The default is 3. Increase it if users report seeing blank areas during fast scrolling.

Loading Data on Demand with ItemsProvider

For truly large datasets, you don't want to load everything upfront. The ItemsProvider delegate fetches data as the user scrolls:

razor
<div style="height: 600px; overflow-y: auto;">
    <Virtualize ItemsProvider="LoadItems" Context="item" ItemSize="60">
        <div class="list-item">
            <strong>@item.Name</strong>
            <span>@item.Category</span>
        </div>
    </Virtualize>
</div>

@code {
    private async ValueTask<ItemsProviderResult<Product>> LoadItems(
        ItemsProviderRequest request)
    {
        var result = await ProductService.GetPagedAsync(
            startIndex: request.StartIndex,
            count: request.Count,
            cancellationToken: request.CancellationToken);

        return new ItemsProviderResult<Product>(
            result.Items,
            result.TotalCount);
    }
}

The ItemsProviderRequest tells you which items are needed (by index and count). Your backend returns just that slice, plus the total count so the scrollbar can be sized correctly.

This is powerful for database-backed lists. Combined with EF Core:

Example.cs
private async ValueTask<ItemsProviderResult<Product>> LoadItems(
    ItemsProviderRequest request)
{
    var count = await Db.Products.CountAsync(request.CancellationToken);

    var items = await Db.Products
        .OrderBy(p => p.Name)
        .Skip(request.StartIndex)
        .Take(request.Count)
        .ToListAsync(request.CancellationToken);

    return new ItemsProviderResult<Product>(items, count);
}

Placeholder Content

While items are loading (relevant with ItemsProvider), you can show placeholder content:

razor
<Virtualize ItemsProvider="LoadItems" Context="item" ItemSize="60">
    <ItemContent>
        <div class="list-item">
            <strong>@item.Name</strong>
        </div>
    </ItemContent>
    <Placeholder>
        <div class="list-item skeleton">
            <div class="skeleton-text"></div>
        </div>
    </Placeholder>
</Virtualize>

The placeholder renders in place of items that haven't loaded yet, giving a skeleton loading effect.

Virtualised Tables

You can virtualise table rows too:

razor
<table>
    <thead>
        <tr>
            <th>Name</th>
            <th>Email</th>
            <th>Department</th>
        </tr>
    </thead>
    <tbody>
        <Virtualize Items="employees" Context="emp" ItemSize="40">
            <tr>
                <td>@emp.Name</td>
                <td>@emp.Email</td>
                <td>@emp.Department</td>
            </tr>
        </Virtualize>
    </tbody>
</table>

This works but note that the <table> element needs to be inside a scrollable container, and the browser's table layout algorithm may interfere with virtualisation. For complex table scenarios, QuickGrid with its ItemsProvider is often a better fit.

Refreshing Virtualised Data

When your data changes, call RefreshDataAsync to force the Virtualize component to reload:

razor
<Virtualize @ref="virtualise" ItemsProvider="LoadItems" Context="item">
    <div>@item.Name</div>
</Virtualize>

<button @onclick="Refresh">Refresh</button>

@code {
    private Virtualize<Product>? virtualise;

    private async Task Refresh()
    {
        if (virtualise is not null)
        {
            await virtualise.RefreshDataAsync();
        }
    }
}

When Not to Virtualise

Virtualisation adds complexity. Don't use it for lists under a few hundred items — the overhead isn't worth it. Also avoid it when items have wildly varying heights, when you need all items in the DOM for accessibility or search (Ctrl+F), or when you need to programmatically scroll to a specific item (though this is possible with some effort).

For most business applications, virtualisation becomes necessary at around 500-1000 items. Below that threshold, a simple @foreach with pagination is simpler and equally performant.