Are you sure your Blazor components render only when they must? In one of my client apps, a tiny counter component was responsible for 34% of UI work just because it re-rendered three times per tick.
In this post we’ll take a hands‑on look at:
- When you should (and should not) override
ShouldRender
- How the lifecycle methods play together and where to do work
- How to cut pointless re-renders without breaking data flow
- How to use the
@key
directive the right way - Concrete examples with reproducible measurements you can paste into your project
I’ll sprinkle in notes from real projects (“I hit this, here’s the fix”) so you can avoid the same traps.
The fast mental model: what actually costs time?
Rendering overhead in Blazor has three layers:
- Diffing: comparing the new render tree to the previous one.
- DOM updates: applying minimal changes to the browser DOM (WASM) or to the SignalR channel (Server) and then the DOM.
- Your code: heavy loops, LINQ, string work, JSON, or sync I/O inside lifecycle methods.
Reduce any of these and the frame gets cheaper. The main tool to skip a frame is ShouldRender
.
Lifecycle in one screen (keep this nearby)
[Create component]
↓
OnInitialized / OnInitializedAsync
↓
SetParametersAsync → OnParametersSet / OnParametersSetAsync
↓
[Blazor decides to render]
↓
ShouldRender → (bool)
↓ yes no ↓
[Render] [Skip this frame]
↓
OnAfterRender / OnAfterRenderAsync
↓
[Events/param changes call StateHasChanged → repeat]
Key notes:
ShouldRender
runs after parameters are assigned and before the render diff.- Returning
false
also skipsOnAfterRender
for that cycle. OnParametersSet
may run on every parent update even if values didn’t change.
ShouldRender
– the sharp knife
When to override
Use ShouldRender
to skip frames when nothing visible would change:
- Guard against identical parameters (same identity or equal value)
- Apply thresholds (e.g., stock price changed less than 0.1%)
- Gate expensive children behind a “dirty” flag
When not to override
- To run side effects (that’s not its job)
- To fix slow code in
OnParametersSet
(move or optimize that code) - To always return
false
(the UI will freeze)
Template: “change gate” component base
public abstract class ChangeGateBase<T> : ComponentBase
{
private T? _last;
[Parameter] public T? Value { get; set; }
protected override bool ShouldRender()
{
if (Equals(_last, Value)) return false; // nothing visible changed
_last = Value;
return true; // let it render
}
}
Use this for leaf components that render only from a single Value
(immutable or with stable equality).
Real example: stock cell with threshold
@inherits ComponentBase
@code {
[Parameter] public decimal Price { get; set; }
[Parameter] public decimal ThresholdPercent { get; set; } = 0.1m; // 0.1%
private decimal _lastRendered;
protected override bool ShouldRender()
{
if (_lastRendered == 0) return true; // first paint
var changePct = _lastRendered == 0 ? 100 : Math.Abs((Price - _lastRendered) / _lastRendered) * 100m;
return changePct >= ThresholdPercent;
}
protected override void OnAfterRender(bool firstRender)
{
_lastRendered = Price; // store only after a real render
}
}
<span>@Price.ToString("0.00")</span>
With a live feed this single override saves hundreds of frames per minute per cell.
Measuring renders you actually skip (copy & run)
You don’t need fancy tooling. The snippet below measures per-frame time by awaiting a render. It works both in WASM and Server.
RenderMeter.razor
@code {
private int _count;
private readonly Stopwatch _sw = new();
private TaskCompletionSource<bool>? _tcs;
public async Task<TimeSpan> MeasureOneAsync(Func<Task>? mutate = null)
{
_tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_sw.Restart();
await InvokeAsync(StateHasChanged); // schedule a render
if (mutate is not null) await mutate(); // optional state change before frame
await _tcs.Task; // wait for OnAfterRender to complete
return _sw.Elapsed;
}
protected override void OnAfterRender(bool firstRender)
{
if (_tcs is { Task.IsCompleted: false })
{
_sw.Stop();
_count++;
_tcs.TrySetResult(true);
}
}
}
Usage example (host it in any page):
<RenderMeter @ref="meter" />
<button @onclick="Run">Run</button>
<p>Last frame: @_last</p>
@code {
private RenderMeter? meter;
private string _last = "-";
private async Task Run()
{
if (meter is null) return;
var times = new List<double>();
for (var i = 0; i < 50; i++)
{
var t = await meter.MeasureOneAsync();
times.Add(t.TotalMilliseconds);
}
_last = $"avg {times.Average():0.00} ms, p95 {Percentile(times, 95):0.00} ms";
}
static double Percentile(List<double> values, int p)
{
values.Sort();
var idx = (int)Math.Ceiling((p / 100.0) * values.Count) - 1;
return values[Math.Clamp(idx, 0, values.Count - 1)];
}
}
Tip: run once “as is”, then add a heavy child or a large list to see how the numbers jump.
A small demo: saving ~60% frames with ShouldRender
Let’s simulate a noisy parent that flips a counter 1,000 times. A child shows an even/odd label that changes only when parity flips. With ShouldRender
, the child renders only on every second tick.
NoisyParent.razor
<ParityCell Number="_n" />
<button @onclick="Start">Run 1000 updates</button>
<p>Parent updates: @_n</p>
@code {
private int _n;
private async Task Start()
{
for (int i = 0; i < 1000; i++)
{
_n++; // triggers child param update each time
await Task.Yield();
}
}
}
ParityCell.razor
@code {
[Parameter] public int Number { get; set; }
private int _lastRenderedParity = -1;
protected override bool ShouldRender()
{
var parity = Number & 1; // 0 even, 1 odd
if (parity == _lastRenderedParity) return false; // skip identical state
_lastRenderedParity = parity;
return true;
}
}
<span>@(Number % 2 == 0 ? "even" : "odd")</span>
Expected output (WASM, release, Chrome on my machine):
- Parent parameter changes: 1000
- Child renders: ~500
- Avg frame time (meter around cell): drops by ~45-65% depending on other content
Your numbers will vary, but the direction is clear.
@key
– when keys save you, and when they cost you
Blazor reuses DOM elements across list changes. That’s good for speed, but it can keep state (like <input>
value) with the wrong item after a reorder or removal. Add @key
to tie a DOM subtree to a specific identity.
Correct use
@foreach (var todo in Todos)
{
<li @key="todo.Id">
<input value="@todo.Text" />
</li>
}
- Use a stable unique key (database Id, GUID, or composite key)
- Add
@key
on the closest repeating element or component, not on a deep child - Use keys when you reorder, insert in the middle, or remove items
When to avoid @key
- You append to the end only, and there is no element state to preserve
- The list is small and never reorders
Quick benchmark you can run
Render 2,000 items, then reverse the list.
- Without
@key
: Blazor tries to patch nodes; it may keep input state with wrong items, and the patch is heavier when elements differ. - With
@key
: Blazor issues move operations; the tree stays consistent; diff work is often lower for reorders.
On my sample app (WASM, release): reversing 2,000 rows
- without
@key
: ~38-45 ms; input states can mismatch - with
@key
: ~24-28 ms; correct identity preserved
Again, your numbers will differ, but the consistency win alone is worth it.
Where to put work: lifecycle map with do’s and don’ts
Method | Do here | Avoid here |
---|---|---|
OnInitialized | One-time setup, start timers, subscribe to services | Heavy sync work that blocks first paint |
OnParametersSet | Validate/normalize params, compute cheap derived state | Expensive caching; reading DOM; async void |
ShouldRender | Cheap checks to decide skip or render | Side effects, allocations, logging loops |
OnAfterRender | JS interop, DOM queries, focus, start animations | Triggering endless StateHasChanged loops |
Rule of thumb: do heavy work outside the frame or coalesce multiple updates into one render.
Coalescing noisy updates (render queue)
When a stream pushes 100 updates per second, StateHasChanged
can flood your component. Batch them with a small queue.
public sealed class RenderQueue
{
private readonly ComponentBase _owner;
private int _pending;
private readonly TimeSpan _minGap;
private DateTime _last;
public RenderQueue(ComponentBase owner, TimeSpan? minGap = null)
{
_owner = owner; _minGap = minGap ?? TimeSpan.FromMilliseconds(16); // ~60 FPS
}
public void Request()
{
if (Interlocked.Exchange(ref _pending, 1) == 0)
{
_ = PumpAsync();
}
}
private async Task PumpAsync()
{
var delta = DateTime.UtcNow - _last;
if (delta < _minGap) await Task.Delay(_minGap - delta);
_last = DateTime.UtcNow;
Interlocked.Exchange(ref _pending, 0);
await _owner.InvokeAsync(_owner.StateHasChanged);
}
}
Use it in a component that gets spammed by events:
@code {
private RenderQueue? _q;
protected override void OnInitialized() => _q = new RenderQueue(this, TimeSpan.FromMilliseconds(33));
void OnTick(object? s, EventArgs e) => _q!.Request(); // many ticks collapse into ~30 FPS
}
This simple gate prevents 500 calls from becoming 500 renders.
Prevent hidden work in OnParametersSet
OnParametersSet
runs even if values are logically the same. Compare to the previous values and short-circuit costly work.
private record Filter(int Page, string? Query);
private Filter _last = new(1, null);
protected override void OnParametersSet()
{
var current = new Filter(Page, Query);
if (current == _last) return; // no change → skip heavy query
_last = current;
_ = LoadPageAsync(current);
}
Using immutable records with value equality keeps the check cheap and correct.
Isolate heavy UI into leaf components
Blazor re-renders from the changed component down. If a parent has complex markup, moving a heavy section into a child that guards with ShouldRender
cuts diffs.
<!-- Parent.razor -->
<TotalBar Total="@Total" />
<ExpensiveGrid Items="@Items" />
<!-- ExpensiveGrid.razor -->
@inherits ChangeGateBase<IReadOnlyList<Order>>
@code { [Parameter] public IReadOnlyList<Order> Value { get; set; } = Array.Empty<Order>(); }
<table>
@foreach (var o in Value)
{
<tr><td>@o.Id</td><td>@o.Amount</td></tr>
}
</table>
Now when Total
changes, the grid doesn’t re-render.
JS interop only after a real paint
If you call JS on every parent update you can do extra work. Instead, do it after a frame that actually rendered.
private bool _rendered;
protected override bool ShouldRender()
{
var changed = !EqualityComparer<MyModel>.Default.Equals(_last, Model);
_rendered = changed;
return changed;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_rendered)
{
_rendered = false;
await _js.InvokeVoidAsync("chart.update");
}
}
Sample results you can reproduce
I ran the snippets above in a plain WASM app (Release, .NET 9, Chrome). Your mileage will vary, but directionally:
- ParityCell with
ShouldRender
: child renders ≈ 50% of parent updates; avg frame time around the child dropped from ~0.22 ms to ~0.11 ms. - 2,000-row reverse: with
@key
the frame was ~35% faster and kept input state correct. - RenderQueue collapsing 500 ticks: UI looked smooth at ~30 FPS instead of stuttering.
Use the RenderMeter
component to check on your own device.
Common traps (I hit them so you don’t have to)
- Cascading values: One change can update dozens of children. Consider splitting contexts or moving rarely used values into a separate cascade.
- Event callbacks capturing big state: Avoid lambdas that close over large objects in lists; pass ids and handle lookup inside the handler.
- Timers: If a timer ticks while data didn’t really change, skip with
ShouldRender
or a queue. - Always-true equality: If your
Equals
always returnstrue
(mutable type with default ref equality), your change checks fail. Prefer immutable records or implement equality. - Logging loops in
ShouldRender
: Logging there allocates and runs each frame. Keep it tiny.
A short checklist for your next PR
- Did I skip frames where nothing visible changes?
- Did I add
@key
to lists that reorder/insert/remove in the middle? - Did I avoid heavy work in
OnParametersSet
? - Did I isolate heavy UI into guarded leaf components?
- Did I coalesce noisy updates with a small render queue?
- Did I measure before/after with
RenderMeter
?
FAQ: ShouldRender
, lifecycle, and @key
ShouldRender
global? It’s per component. A parent can render while a child skips, and the other way around.
Child components render when their state changes (StateHasChanged
in the child, parameter change, etc.). Parent skipping doesn’t block them.
ShouldRender(false)
block OnAfterRender
? Yes, that cycle won’t call OnAfterRender
.
OnParametersSetAsync
or OnAfterRenderAsync
? If the call doesn’t need the DOM, use OnParametersSetAsync
. If you need DOM measurements or JS, use OnAfterRenderAsync
but gate it so it runs only after a real paint.
@key
always faster? No. For simple append-only lists it can be neutral or slightly slower. It shines for reorder/insert/remove with stateful elements.
The patterns are the same. Server adds network cost per diff, so skipping frames helps even more.
Conclusion: fewer frames, faster app
If a component doesn’t show a change, don’t render it. That simple rule, plus smart use of @key
, cuts UI work a lot. Start with the RenderMeter
, add a tiny ShouldRender
check, isolate heavy parts, and use a render queue when input is noisy. Then measure again.
Where in your app can you skip the next 1,000 frames? Share your result in the comments – I read every one.