If you've built a chat interface, a social feed, or any list where items vary in height, you've probably run into the same problem with Blazor's Virtualize component: scroll positions jump, spacers overshoot, and the whole thing feels broken. The root cause was a fixed-height assumption baked into the component since it shipped in .NET 5. Every item was assumed to be exactly ItemSize pixels tall (defaulting to 50px), and any deviation from that caused the spacer calculations to drift.
That changes in .NET 11 Preview 3. The Virtualize<TItem> component now measures actual rendered heights and maintains a running average, adapting in real time as items enter the viewport. The ItemSize parameter becomes an initial hint rather than a hard contract, and the long-standing GitHub issue #25058 — open since August 2020 — is finally resolved.
The fixed-height problem
To understand why this matters, consider what Virtualize does under the hood. It renders only the items currently visible in the viewport plus a buffer (the overscan), and uses two spacer div elements — one above and one below the rendered items — to maintain the correct scroll height. The height of each spacer is calculated as:
spacer height = number of off-screen items × assumed item height
When every item is the same height, this works perfectly. The scroll thumb is accurate, jump-scrolling lands in the right place, and the spacers push content to the correct position.
But the moment items have different heights — a message with one line next to one with a paragraph, a product card with an image next to one without — those spacer calculations are wrong. The component thinks the first 50 items above the viewport take up 50 × 50px = 2500px, when they actually take up 4,800px. The result: content overlaps, the scroll position jumps when items are recycled, and users see flashes of misplaced content.
Before .NET 11, the best workarounds were to force all items to a fixed height (losing design flexibility), to implement your own virtualisation from scratch with JavaScript interop, or to reach for a third-party component library. None of these were satisfying.
How the running average works
The new implementation takes a pragmatic approach rather than tracking every individual item's height. It maintains three values:
- Total measured height — the cumulative sum of all measured item heights
- Measured item count — how many items have been measured
- Average measured height — the running mean, calculated as
totalMeasuredHeight / measuredItemCount
When the component first renders, no measurements exist yet, so it falls back to the ItemSize parameter (still defaulting to 50px). As items scroll into the viewport and are rendered to the DOM, the component measures their actual heights using the browser's Range API and updates the running average.
The spacer calculation then becomes:
spacer height = number of off-screen items × running average height
This is an O(1) memory approach — there's no dictionary mapping item indices to pixel heights, no matter how large the dataset. The average converges towards the true mean as more items are measured, and for most real-world data distributions this happens quickly.
// TIP
If your items have a known average height, set ItemSize to that value. This improves the accuracy of the initial render before any measurements have been taken.
What changed in the API
The good news is that the API surface barely changed. If your existing Virtualize usage works with fixed-height items, it will continue to work. The key difference is a new default for OverscanCount.
OverscanCount: from 3 to 15
The most visible breaking change is the OverscanCount default, which jumped from 3 to 15. This isn't arbitrary — the running average needs a reasonable sample size to converge. With only 3 overscan items, the component measures very few items on each render cycle, producing noisy estimates. With 15, the initial measurements are more representative, and the spacer heights stabilise faster.
<!-- .NET 10 default behaviour: 3 overscan items -->
<Virtualize Items="messages" Context="msg">
<MessageBubble Message="msg" />
</Virtualize>
<!-- .NET 11 default behaviour: 15 overscan items -->
<!-- Same markup, different default -->
<Virtualize Items="messages" Context="msg">
<MessageBubble Message="msg" />
</Virtualize>
If you're working with very expensive-to-render items (complex components, heavy interop), you might want to reduce this:
<Virtualize Items="messages" Context="msg" OverscanCount="8">
<MessageBubble Message="msg" />
</Virtualize>
// WARNING
Setting OverscanCount too low (below 5) with variable-height items will produce poor initial estimates and more visible layout shifts during scrolling.
Note that QuickGrid retains the old default of 3 to minimise the performance impact on table rendering, where row heights tend to be more uniform.
ItemSize becomes an initial estimate
Previously, ItemSize was the single source of truth for positioning calculations. Now it serves as the starting estimate before any measurements exist. Once items are measured, the running average takes over entirely.
<!-- Good — provides a realistic starting estimate for a chat UI -->
<Virtualize Items="messages" Context="msg" ItemSize="80">
<div class="message">
<span class="author">@msg.Author</span>
<p>@msg.Content</p>
</div>
</Virtualize>
If your items are roughly 80px tall on average, setting ItemSize="80" means the initial render before any measurements will be closer to correct, reducing the first-frame layout shift.
Real-world examples
Chat application
A chat interface is the classic case for variable-height items. Messages range from a single emoji to multi-paragraph blocks with embedded links or code snippets.
<div class="chat-container" style="height: 600px; overflow-y: auto;">
<Virtualize Items="messages" Context="msg" ItemSize="72">
<div class="chat-bubble @(msg.IsOwn ? "own" : "other")" @key="msg.Id">
<span class="sender">@msg.SenderName</span>
<div class="content">@((MarkupString)msg.HtmlContent)</div>
<time>@msg.SentAt.ToString("HH:mm")</time>
</div>
</Virtualize>
</div>
Before .NET 11, this would produce scroll jumps whenever a short message was adjacent to a long one. The spacer above would be undersized, causing the content to shift downward as the user scrolled up. With the running average, the spacers converge on the correct height after a few scroll interactions.
Activity feed with mixed content
Feeds that mix text posts, image cards, and compact status updates are another natural fit:
<div class="feed" style="height: calc(100vh - 64px); overflow-y: auto;">
<Virtualize ItemsProvider="LoadActivities" Context="activity" ItemSize="120">
<ItemContent>
@switch (activity.Type)
{
case ActivityType.TextPost:
<TextPostCard Post="activity" />
break;
case ActivityType.ImagePost:
<ImagePostCard Post="activity" />
break;
case ActivityType.StatusUpdate:
<StatusUpdateRow Status="activity" />
break;
}
</ItemContent>
<Placeholder>
<div class="skeleton-card" style="height: 120px;"></div>
</Placeholder>
</Virtualize>
</div>
The Placeholder template should still use a fixed height matching your ItemSize estimate. Placeholders render before the real item data arrives, and the running average only kicks in once actual items are measured.
Data grid with wrapping text
Even table-like layouts benefit when cells contain variable-length text that wraps:
<table>
<thead>
<tr>
<th>Order</th>
<th>Customer</th>
<th>Notes</th>
<th>Total</th>
</tr>
</thead>
<tbody>
<Virtualize Items="orders" Context="order" SpacerElement="tr" ItemSize="48">
<tr @key="order.Id">
<td>@order.Reference</td>
<td>@order.CustomerName</td>
<td class="notes-cell">@order.Notes</td>
<td>@order.Total.ToString("C")</td>
</tr>
</Virtualize>
</tbody>
</table>
The Notes column might wrap to two or three lines on some rows and stay on one line for others. The component now handles this correctly without the developer needing to cap the cell height or truncate the text.
Under the hood: measurement via the Range API
The JavaScript side of the implementation is worth understanding if you're debugging layout issues. Rather than querying each item's getBoundingClientRect() individually, the component uses HTML comment delimiters (<!--virtualize:item-->) inserted between items during rendering. It then creates a Range object spanning from the first to the last delimiter and measures the total height in a single call.
This approach has two advantages:
- Fewer DOM queries — one measurement per render batch rather than one per item
- Natural inclusion of margins and spacing — the Range API captures the full visual extent between boundaries, including CSS
gap,margin, and tableborder-spacing
A 50ms throttle is applied to scroll callbacks to prevent rapid-fire re-measurements that could cause oscillation — where measuring triggers a spacer resize, which triggers a scroll event, which triggers another measurement.
Migration from .NET 10
If you're upgrading an existing Blazor application, here's what to expect:
No code changes required for fixed-height lists. If all your items are the same height, the new behaviour is functionally identical. The running average will converge to your actual item height.
OverscanCount default changed from 3 to 15. This renders 12 more items above and below the viewport. For simple items, this is negligible. For expensive components, profile and adjust:
<Virtualize Items="items" Context="item" OverscanCount="5">
<ExpensiveComponent Data="item" />
</Virtualize>
Remove fixed-height workarounds. If you were forcing items to a fixed height purely to satisfy Virtualize, you can now let them size naturally.
Test scroll behaviour. The running average is most accurate when items have moderate variance. Lists with extreme height differences (e.g. 20px to 2,000px) will take longer to converge, and the scrollbar thumb may visibly resize as the estimate refines itself.
Common pitfalls
Setting ItemSize to 0 or a wildly wrong value. The initial render uses ItemSize before any measurements exist. A value of 0 means the component thinks all off-screen items take up no space, resulting in the entire dataset appearing to fit in the viewport — triggering a massive over-render. Always set ItemSize to a reasonable estimate of your average item height.
Expecting pixel-perfect scroll position on first render. The running average needs data. On the very first render, especially when jumping to a scroll position far from the top, the positions are estimates. They correct themselves as items are measured, but the first frame might shift slightly. This is inherent to any estimated virtualisation approach.
Forgetting that container width affects item height. If your items contain wrapping text, resizing the browser window changes item heights. The current implementation does not automatically invalidate cached measurements when the container width changes. If you handle window resize events, call RefreshDataAsync() on the Virtualize component to force re-measurement:
@implements IDisposable
<div class="list-container" style="height: 500px; overflow-y: auto;">
<Virtualize @ref="virtualise" Items="items" Context="item" ItemSize="60">
<div class="flexible-item">@item.Content</div>
</Virtualize>
</div>
@code {
private Virtualize<ContentItem>? virtualise;
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
// Subscribe to window resize via JS interop and call OnResize
}
}
private async Task OnResize()
{
if (virtualise is not null)
await virtualise.RefreshDataAsync();
}
public void Dispose()
{
// Unsubscribe from resize events
}
}
Mixing Virtualize with CSS transforms. The PR also fixes an existing bug where CSS transform: scale() on ancestor elements caused incorrect scroll calculations. If you were working around this issue, you can remove those workarounds in .NET 11.
Reducing OverscanCount below 5 for variable-height content. The running average is only as good as its sample. With a tiny overscan, the component measures very few items per scroll interaction, producing noisy estimates and more visible layout corrections.
Summary
Virtualize<TItem>in .NET 11 Preview 3 measures actual item heights using a running average rather than assuming a fixedItemSize.- The
OverscanCountdefault increased from3to15to improve measurement accuracy.QuickGridretains the old default of3. ItemSizenow serves as an initial estimate before measurements begin — set it to your expected average height.- The implementation uses HTML comment delimiters and the browser's
RangeAPI for efficient batch measurement with a 50ms scroll throttle. - No code changes are required for existing fixed-height usage. For variable-height lists, remove any fixed-height workarounds and test scroll behaviour.
- This resolves GitHub issue #25058, open since August 2020.