Skip to content

Scenario · Rendering

Products

A searchable list of 120 products, built the way lists often are at first: a state container that notifies on every change, a list that re-renders on every notification, and rows that never override ShouldRender. Each keystroke costs a render of every row - and, with search on type, an HTTP request. Three more problems hide on this page: data loaded twice, a component that is never freed, and a fix to measure.

The product list

Type in the search box. FilterBar writes to FilterState, which raises Changed without coalescing; ProductList re-renders, and every ProductRow with it. Add puts a product in the cart, a named change of CartState. The line above the search box is CategorySummary, which loads the products a second time; price alert shows a PriceAlert that subscribes to FilterState and never unsubscribes.

Live Renderer: Static · prerendering CtrlShiftD opens BevTools
Cart: 0 items, $0.00 — see BevTools → State → CartState
120 products in 5 categories

Products (0 of 0) · rendered 0 times · filtering locally

NameCategoryPriceStockRating

Profiler: what one keystroke costs

Open the Profiler, press Reset, type one character and tick since reset; tick by type to see all the rows as one line - what 120 ProductRows cost together, and that every one of their renders was a parent re-render.

  • Recent render batches lists one batch per keystroke with how many components it rendered, how long building and diffing took, how many DOM edits it made and what triggered it. Select one: its cost tree puts ProductRow ×120 instances under ProductList, each with its own time and its subtree's; Time draws the same renders on a time axis, in the order the renderer ran them.
  • Diagnostics names the pattern: a render cascade - a keystroke's batch renders the list and every row it shows - and renders driven only by the parent for the rows. Drag the max price slider and a render storm on ProductList joins them: every step of the slider is a notification.

Timeline: from the keystroke to the batch

Each oninput → FilterBar (@bind) event is followed by a FilterState change (the demo's extension shows that store in the State panel) and one batch with 121 renders. Select the event and the Timeline lists what it caused; select one of those renders and caused by leads back to the keystroke. The chips above the list show or hide each kind of entry.

Components: what the search box runs

  • Press ⌖ Pick in the panel header and click the search box: the inspector opens on FilterBar. Its Event handlers & callbacks, read from its latest render, list each element with the event it handles and the method that runs - @bind-value → FilterBar.OnQuery for the search box - each with a link to its line in FilterBar.razor.
  • Select ProductList: the same section lists the OnAdd callback it passes to every row, and the method it runs (CartState.Add).

Network: a request per keystroke

Tick search the API on every keystroke and type quickly. The Network panel fills with GET /api/search?q=… requests, each triggered by an oninput event (the Trigger column), none cancelled, each answer free to overwrite a newer one. Diagnostics reports a burst of requests to one endpoint: how many in how little time, the event that sent them, and - when the answers are slow enough to overlap - how many started while the previous one was still running.

On a Server circuit, Network → WebSockets shows what each keystroke costs on the wire: every JS.RenderBatch the circuit sent, its size - about 100 KB while all 120 rows re-render - and how long the browser took to apply it.

Runtime: the products are loaded twice

Reload this tab. The server prerenders the page, then it becomes interactive, and CategorySummary loads the products in OnInitializedAsync, which runs both times. Runtime → Prerendering shows what the server rendered, how long it took and which requests it made; Diagnostics reports Data fetched twice: while prerendering and when interactive.

Runtime: a hidden alert that is never freed

Tick and untick price alert a few times. Each PriceAlert subscribed to the scoped FilterState with a lambda and never unsubscribed, so the store keeps every one alive. Runtime → Disposed components still alive → Collect garbage and check lists them, with what holds each one: FilterState (injected) → Changed: lambda in PriceAlert.OnInitialized. The demo extension's Store tab shows the same leak from the store's side: one more subscriber per alert.

Measure the fix

  1. In the Profiler press Reset, type lamp, then About → Export session (JSON): that file is the recording before the fix.
  2. Tick the fix: rows skip renders that would change nothing, press Reset and type lamp again.
  3. Profiler → Compare… with the file: ProductRow is improved, with its renders before and after. Copy as Markdown puts the comparison in a pull request; in CI, bevtools compare before.json after.json does the same with two files.

The other fixes to try in code:

  • Debounce FilterState, so a burst of keystrokes is one notification.
  • Pass a CancellationToken to the search and cancel it on the next keystroke.
  • Carry CategorySummary's list over from prerendering with [PersistentState] (.NET 10) or PersistentComponentState.
  • Keep PriceAlert's handler in a field, implement IDisposable and unsubscribe in Dispose.

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.

An unhandled error has occurred. Reload 🗙