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.
Products (0 of 0) · rendered 0 times · filtering locally
| Name | Category | Price | Stock | Rating |
|---|
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 instancesunderProductList, 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
ProductListjoins 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.OnQueryfor the search box - each with a link to its line inFilterBar.razor. - Select
ProductList: the same section lists theOnAddcallback 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
- In the Profiler press Reset, type lamp, then About → Export session (JSON): that file is the recording before the fix.
- Tick the fix: rows skip renders that would change nothing, press Reset and type lamp again.
- Profiler → Compare… with the file:
ProductRowis improved, with its renders before and after. Copy as Markdown puts the comparison in a pull request; in CI,bevtools compare before.json after.jsondoes the same with two files.
The other fixes to try in code:
- Debounce
FilterState, so a burst of keystrokes is one notification. - Pass a
CancellationTokento the search and cancel it on the next keystroke. - Carry
CategorySummary's list over from prerendering with[PersistentState](.NET 10) orPersistentComponentState. - Keep
PriceAlert's handler in a field, implementIDisposableand unsubscribe inDispose.