Reduce number of components that are rendered per item
Author: danielchalmersCreated Aug 27, 2026Updated Sep 15, 2026
Labelsperformance
### Summary
MudBlazor uses components as its styling vocabulary: a MudText to emit a styled `
`, a MudIcon for an ``, a MudElement for a `
`. Each of those costs a full Blazor component, and in a repeated list that choice dominates everything else.
The rule of thumb this issue tracks: Components at the widget boundary, markup inside it. A component may compose other components at its top level, but the content it repeats per item should be plain elements and CSS classes.
### Status by release
#### v9.9.0 (released 2026-08-24)
Nothing in this release removed components per item.
#### v9.10.0 (released 2026-09-13)
| PR | what |
| --- | --- |
| #13707 | MudText renders its element directly |
| #13731 | MudStack, MudButtonGroup, MudRTLProvider render their element directly |
| #13793 | MudTreeViewItem skips the toggle button for leaves |
| #13802 | MudTr renders the selection cell as plain markup |
| #13816 | MudPagination renders its list element directly |
#### v9.11.0 (current)
Merged:
| PR | what |
| --- | --- |
| #13800 | MudButton, MudIconButton, MudFab render their root directly |
| #13794 | MudListItem renders its row directly |
| #13801 | MudMenuItem renders its row directly |
| #13815 | MudChip renders its element directly |
| #13817 | AGENTS.md "Root element" rule, `MudElement.CaptureRef` removed |
| #13843 | MudListItem, MudMenuItem, MudTreeViewItem, MudCheckBox, MudRadio, MudSwitch render their text without MudText |
| #13868 | MudDatePicker's year view renders its 201 years as markup instead of MudText components (timings in #13856) |
Retained memory against v9.10.0, 100 instances unless it is a page, median of 5. The numbers include every change merged for v9.11.0, not only the PRs above:
| scenario | v9.10.0 | dev | change |
| --- | --- | --- | --- |
| list items | 2034 KB | 773 KB | -62% |
| open menu | 2243 KB | 914 KB | -59% |
| buttons | 1097 KB | 541 KB | -51% |
| open select | 3248 KB | 1905 KB | -41% |
| admin page (app shell, filters, grid with row actions) | 1072 KB | 842 KB | -21% |
| settings form | 827 KB | 710 KB | -14% |
v9.11.0 density work is complete.
#### v9.x (future)
No more density work until the changes from v9.9.0 through v9.11.0 have proven stable, so the items below will probably land in v9.13.0 or later.
- MudSelectItem rendering its own row instead of wrapping MudListItem, one extra component per option. At realistic option counts (under a few hundred) it is only worth doing if it stays DOM-identical and small.
#### v10 (next breaking release)
These change the DOM or a public API.
| change | measured | what breaks |
| --- | --- | --- |
| Render a MudMenu or MudTooltip popover only while it is open | 100 closed menus -56%, DataGrid 100 columns -36% (column options menus), 100 tooltips -22%, admin page -12% | A closed menu or tooltip no longer emits its empty `mud-popover` element in the provider or the `mud-popover-cascading-value` placeholder next to the activator. The first-open transition needs checking. |
| MudTable cells as markup, the way MudDataGrid does it | 100 rows 1787 -> 724 KB (-60%); `` produces the same DOM as MudTd | `RowTemplate` API |
| MudSelect without shadow items | a closed select holds one MudSelectItem per option, 4.4 KB each, at page load; also removes the per-open shadow item rebuild #13851 keeps | `IsValueInList` and `CanRenderValue` need replacing; `Strict` and the displayed text depend on them |
| Input rework | a MudTextField is about 42 KB and 6 components; MudInputControl and MudInputLabel as markup: switches -27%, text fields -12%, checkboxes -9%, settings form -7%; dropping the two `SubscribeToParentForm` cascades: at most -10% per field | the cascade becomes a parameter, and the markup change touches every input |
| Icon inlining, on hold | 2.4-5.7 KB retained per icon; see below | nothing in the DOM; held as not worth the extra code in a minor |
##### Icon inlining (prototyped for v9.11.0, put on hold)
Library components would call an internal `MudIcon.RenderIcon(...)` fragment, beside `MudText.RenderText`, instead of creating a MudIcon. It was built at 38 call sites in 24 components with an identical DOM across 108 scenarios and a green suite. It was held because the gain is modest for the extra code, MudInputAdornment and MudSwitch's thumb could not use it as built, and interactive parity was never A/B tested.
Retained memory saved against `dev` after #13843:
| scenario | saved |
| --- | --- |
| per MudRating (5 stars) | 13.0 KB |
| per checkbox, radio, numeric field, expansion panel or DataGrid column | 5.6-5.7 KB |
| admin page | 72 KB of 843 KB |
| toolbar, tabs, fields and a 25-row table with ratings | 376 KB of 923 KB |
Notes for picking it back up
- Call it as an expression, `@MudIcon.RenderIcon(...)`, so it renders into the builder in scope (including child-content lambdas) and keeps the whitespace node before the icon in MudCheckBox, MudRadio and MudRatingItem.
- An inline icon is rebuilt with its parent. Cache the class string per (size, color, disabled, svg) to keep that near 0.3 KB per icon per parent render.
- Match CssBuilder for a custom class (`null`, `""` and whitespace add nothing; trim the result), and cover the helper with one parity test over every icon kind, color, size, disabled and class value.
- MudInputAdornment needs `aria-label` and `tabindex`, and MudSwitch's thumb a style, including MudIcon's rule that a non-blank `aria-label` removes `aria-hidden`.
- Skip icons that appear once per page. Tests using `FindComponent()` inside converted components have to query the DOM, and the release notes need the same line as #13843.
### Measured and dropped
Too small at realistic item counts, or already covered by the items above:
- Fixed cascades and render guards on MudMenu, MudRating, MudTimeline, MudNavMenu, MudNavGroup and MudCarousel: -9% to -16% retained on that component. Their re-render cost is tracked in #13856; the MudCard one (#13867) was closed as not worth the maintenance cost.
- MudNavLink computing its own active class instead of wrapping NavLink: at most -22% on a nav menu and -4% on the admin page, and it would re-implement NavLink's matching.
- MudChip close button as markup: after icon inlining a closable chip is two components; the rest is a few KB per chip.
- DataGrid sort button: covered by icon inlining.
- MudBreadcrumbs inlining its item components: about 6 KB per crumb.
- Earlier: the `OpenRoot` disposable form, MudTabs skipping MudTooltip, hoisting DataGrid's validator cascade, MudRadio inlining its label, deleting `MudList.Update()`, caching `RenderFragment`s and chasing `enum`, `Guid`, `EventCallback` or nullable parameters. #13680, #13694 and #13702 were closed as noise.
### Traps
- `class` and `style` precedence depends on where the `UserAttributes` splat sits: a caller's value replaces the computed one on about 23 components and is dropped on about 87. Keep the order a component had and pin it with tests.
- A lowercase `class` passed to a component binds to its `Class` parameter, because parameter names match case-insensitively. Markup that replaces the component has to merge it the same way.
- A consumer test using `FindComponent()`, `FindComponent()` or `FindComponent()` inside a library component stops finding it. Call it out in the release notes.
- Inlining a component that wrapped `ChildContent` creates the components inside it one render step earlier, which can change order-dependent state such as `MudForm.Errors`.
### Reference
Where the time goes
1000 list rows in the same headless Chrome, Debug WASM test viewer (a Release build measures the same):
| layer | time |
| --- | --- |
| native `` with 1000 `
How the wrapper removals are written
A library component renders its own root and never wraps itself in MudElement. A fixed tag is markup. A runtime tag is opened from C# with `builder.OpenElement(0, HtmlTag)` and the content stays as markup in a `RenderTreeBuilder __builder` method, which is how [QuickGrid](https://github.com/dotnet/aspnetcore/blob/4b58ad51259280e1340777513694f122501435ab/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor#L41-L110) renders rows and [Virtualize](https://github.com/dotnet/aspnetcore/blob/4b58ad51259280e1340777513694f122501435ab/src/Components/Web/src/Virtualization/Virtualize.cs#L554) renders its `SpacerElement`. The reasoning is [Avoid thousands of component instances](https://learn.microsoft.com/aspnet/core/blazor/performance/rendering#avoid-thousands-of-component-instances). The rule is in AGENTS.md. MudElement stays as a public component for consumers.
What a component costs
Retained per instance, each measured by removing a known number of instances:
| kind | retained |
| --- | --- |
| raw markup | ~0.04 KB |
| CascadingValue | 1.0 KB |
| bare `IComponent` | 1.9 KB |
| MudComponentBase subclass | 3-5 KB |
| MudPopover | 10.5 KB |
Being a component at all costs 20-35x the markup it produces, so the count is what matters. The saving per removed component is smaller inside a parent whose render tree is already large, which is why a page gains less than a per-item scenario.
What decides whether an idea can work
1. Change detection compares value types and `string` by `Equals`; every other reference type is assumed changed. A cached `RenderFragment` measures identical to an inline lambda, so any component taking `ChildContent` re-renders on every parent render.
2. Repeated renders retain memory in proportion to emitted markup. Mount amplification is a permanent per-circuit cost, not just CPU.
3. The first MudPopover on a page brings about 45 KB of shared setup. Sweep N and take the marginal.
4. A component boundary defers fragment evaluation by one render-queue step. Removing it can change render order, not just markup.
5. The bare renderer and bUnit show each option rendering once when a select opens; the browser shows seven. Count renders in the browser before concluding a render path is cheap (#13856 has the probe).
How these numbers were taken
Bare `Renderer` with a no-op `UpdateDisplayAsync`, Release, retained memory after a full collection with the tree still rooted, component count from the live render tree, median of 3 to 5 runs. DOM parity from an HtmlRenderer dump after normalising generated ids. Browser times are headless Chrome against the Debug WASM test viewer, from `mousedown` to 400 ms of no DOM changes, median of 6 opens over 4 runs. Behaviour was checked by an A/B driver that runs the same interactions against two builds and diffs the normalized DOM, focus and input state after every step.
Source: MudBlazor/MudBlazor