Skip to main content
ANVISoftware Solutions
Lesson 13 of 14Advanced20 min

Render Performance

By the end of this lesson

Avoid unnecessary re-rendering in large component trees.

A single Blazor render is cheap. It evaluates your markup, builds a description of what should exist, compares it with the previous description, and sends only the differences. For a page with forty elements, that is work you will never notice.

Cost arrives in three ways. Rendering more components than the change affects, usually because the state that changed lives higher in the tree than it needs to. Doing expensive work in a method that runs on every render. And rendering a great many components at once, which is what a list of five thousand rows is.

Before any technique in this lesson: measure. The browser's performance profiler will show you which renders are slow, and the framework's own timings will show you how often a component renders. Optimising a component that was never the problem adds code, removes clarity, and changes nothing the reader can feel.

@key so Blazor matches rows to rows, not positions to positions
C#
<ul>
    @foreach (var employee in visible)
    {
        <li @key="employee.Id">
            <EmployeeRow Employee="employee" OnSelect="Select" />
        </li>
    }
</ul>

<button type="button" @onclick="SortByDepartment">Sort by department</button>

@code {
    private List<Employee> visible = [];

    private void SortByDepartment() =>
        visible = visible.OrderBy(e => e.Department).ThenBy(e => e.Name).ToList();
}
  • Without @key, Blazor matches the previous output to the new output by position. Row one is compared with row one, whatever is now in it.
  • That is fine while the list only grows at the end. Sort it, filter it, or remove an item from the middle, and position one now holds a different person. Blazor keeps the element that was there and changes its content, so anything the browser owns about that element stays behind: focus, scroll position in a nested area, an open dropdown, half-typed text in an input.
  • With @key, Blazor matches by the key instead. The element for employee 4471 moves with employee 4471, and the browser state moves with it.
  • The key must be stable and unique among its siblings. The loop index is the one choice that defeats the purpose entirely, because it changes exactly when the order does.
  • A key on a component preserves that component instance, so its private fields travel with the row rather than being reassigned to a different person's data. That is the version of this bug that is genuinely hard to diagnose.
  • This is a correctness fix before it is a performance one. It also reduces work, because moving an element is cheaper than rewriting its contents, but the reason to reach for it is the wrong-row bug.
EmployeeRow.razor — where work goes, and a render the component can skip
C#
@code {
    [Parameter, EditorRequired]
    public Employee Employee { get; set; } = default!;

    [Parameter]
    public bool IsSelected { get; set; }

    private string display = "";
    private int renderedEmployeeId;
    private bool renderedSelection;

    protected override void OnParametersSet()
    {
        // Runs on every parameter change, which is every render of the parent.
        // Proportional work only. No sorting, no service calls, no queries.
        display = Employee.Name + " · " + Employee.Department;
    }

    protected override bool ShouldRender()
    {
        if (Employee.Id == renderedEmployeeId && IsSelected == renderedSelection)
        {
            return false;
        }

        renderedEmployeeId = Employee.Id;
        renderedSelection = IsSelected;
        return true;
    }
}
  • OnParametersSet runs every time the parent supplies parameters, and the parent supplies them on every one of its renders — whether or not any value changed. A search box in the parent means this method runs on every keystroke, once per row.
  • Concatenating two strings there is fine. Sorting a thousand items, calling a service, or querying a database is not: that cost is now paid per row per keystroke. Move it behind a check for what actually changed, or out of the component entirely.
  • ShouldRender runs after parameters are set and decides whether to produce output. Returning false skips this component's render, and its children with it, because children are rendered by their parent.
  • It is not consulted for the first render. A component always renders once.
  • The comparison covers exactly the two parameters the markup uses. That is the discipline the whole technique rests on: if the markup reads a third parameter and the comparison does not include it, the row stops responding to that parameter.
  • Note what this cannot see. If Employee is mutated in place by the parent, the id is unchanged and the row is skipped while its data is different. Reference and id comparisons both assume the parent replaces objects rather than editing them.
Virtualize, for a list long enough to justify it
C#
<div class="employee-scroll" style="height: 30rem; overflow-y: auto">
    <Virtualize Items="visible" Context="employee" ItemSize="48">
        <ItemContent>
            <EmployeeRow Employee="employee" OnSelect="Select" />
        </ItemContent>
    </Virtualize>
</div>
  • Virtualize renders the rows in view plus a small buffer, and sizes a spacer above and below so the scrollbar behaves as though every row were present. A list of 5,000 becomes roughly thirty rendered components.
  • ItemSize is the row height in pixels, and it is the figure Blazor uses before it can measure anything. A wrong value shows up as a scrollbar that jumps while you scroll. Rows whose heights vary are a poor fit for this component.
  • The container needs a constrained height and its own scrolling. Without a viewport to measure against, there is nothing to virtualise.
  • ItemsProvider replaces Items when the data is fetched a page at a time from a server. The Placeholder fragment then gives each not-yet-loaded row something to occupy its space, which only applies in that mode.
  • There are costs, and they land on people rather than on the profiler. Rows that are not rendered cannot be found by the browser's own find-on-page, so Ctrl+F stops being a way to locate a colleague. A screen reader user is moving through a list whose contents change as it scrolls. And the row count is not visible anywhere unless you say it.
  • So for a genuinely long list, a search box, a filter and server-side paging often serve people better than a fast scroll through five thousand rows. Virtualize is the right tool when scrolling is the interaction people actually want.

Where render cost actually comes from, in the order worth checking:

  • State higher in the tree than the change. A field on the page that changes on every keystroke re-renders the page and everything under it. Moving that field into the component that owns it is usually the entire fix, and it removes code rather than adding it.
  • Work in a property or method the markup reads. Rendering happens often, so it happens often. A filtered list computed in a property is fine; anything touching a service or a database is a bug waiting for real data.
  • Work in OnParametersSet. It runs on every parameter change, whether or not a value differs.
  • A missing @key on a list that reorders or filters. Correctness first, cost second.
  • Long lists rendered in full, when the reader can see twenty rows.
  • Blazor Server only: every render produces a diff that travels over the connection. A large diff on a slow link is felt as lag even when the server was quick, so reducing what re-renders also reduces what is sent.
  • Very large single components. A file with four hundred lines of markup re-renders as one unit, so splitting it lets Blazor skip the parts that did not change.

Summary

  • Measure before optimising: most components are not the problem, and code added to protect them is cost without benefit
  • @key matches list elements by identity instead of position, which prevents element state attaching to the wrong row
  • OnParametersSet runs on every parameter change, so anything expensive there is paid on every render of the parent
  • ShouldRender can skip a render, and a comparison that misses a parameter produces a UI that silently stops updating
  • Virtualize renders only the rows in view, at a real cost to find-on-page and to people navigating the list with assistive technology

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Find the row that kept the wrong state

Render a list of ten employees where each row contains a text input for a note. Type something into the fourth row, then click a button that sorts the list by department.

Do it without @key and then with @key on the employee id. Where did the text go each time?

Show solution

Without a key the text stays in the fourth position, which is now a different person. Nothing errors. The row renders the right name and the note belongs to somebody else, which is the kind of defect that reaches production because it looks fine in a screenshot.

With a key the input moves with the employee, because Blazor matched the elements by key and moved them rather than rewriting their contents. Focus behaves the same way: if you were still typing, the cursor follows the row.

The reason is worth holding on to. Blazor's diff has no way to know that your list items are identities rather than positions unless you tell it, and the position assumption is correct often enough that the bug only appears when the order changes.

This is why @key belongs on any list that can reorder, filter or have items removed from the middle — not as an optimisation, but because the alternative is wrong.

C#
@* Wrong: matched by position, so notes stay with the row number *@
@foreach (var employee in visible)
{
    <li>
        <span>@employee.Name</span>
        <input @bind="notes[employee.Id]" />
    </li>
}

@* Right: matched by identity, so notes stay with the person *@
@foreach (var employee in visible)
{
    <li @key="employee.Id">
        <span>@employee.Name</span>
        <input @bind="notes[employee.Id]" />
    </li>
}

Challenge

Measure, then decide whether to optimise

Render 2,000 employee rows in a plain @foreach. Record how long the first render takes and how the page feels when you type in a search box above it.

Then try three changes separately: add Virtualize, override ShouldRender on the row, and move the search term out of the page into the search component. Record each result and then argue for the one you would keep.

Show solution

The numbers will differ by machine and hosting model, so the figures matter less than their order. On most setups Virtualize is the largest single improvement, because it removes almost all of the rendering rather than making it cheaper.

Moving the search term is usually second, and it is the one to keep even if it is not the biggest. It is less code than you started with, it cannot be wrong about when to update, and it fixes the cause — a keystroke re-rendering two thousand rows that had nothing to do with it — rather than the symptom.

ShouldRender on the row tends to help least once the other two are in place, and it carries the risk from the trade-off callout. If your measurements say you need it, keep the comparison next to the parameter list and treat adding a parameter as a change to both.

There is a fourth answer the exercise is designed to surface: ask the server for fifty rows. Two thousand rows in a browser is rarely what somebody wanted — they wanted to find one person. A search box with server-side paging is usually faster than all three optimisations combined, and it is less code.

That is the habit worth taking away. Measure, then ask whether the fastest version of this design is the design you should have.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A list is rendered in a @foreach with no @key, and each row contains a text input. The list is re-sorted. What is the most likely symptom?
A row component overrides ShouldRender and returns false unless the employee id changed. A new IsSelected parameter is added and used in the markup, but the comparison is not updated. What happens?

Saved in this browser only.