Prerendering and State Persistence: the Double-Load Problem, [PersistentState] and Circuit State
An interactive component with prerendering runs as two instances, so its data is loaded twice; .NET 10's [PersistentState] embeds the prerendered value in the page and hands it to the interactive instance, and the same attribute keeps state when a circuit pauses and resumes. In the Bookstore panel we measure and remove the double API call and the table flash, use RestoreBehavior for data that must stay fresh, and show that state persisted for WebAssembly is plain JSON in the page source.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15); browser behaviour was observed with Playwright 1.65's headless Chromium.
Predict First
In post 03 we saw OnInitializedAsync run twice in the inventory page's log and left the reason for later. In the admin panel of Bookstore, the online bookstore used as the shared example across this site's series, the /inventory page is InteractiveServer with prerendering on. When the page is first opened, how many requests go to Bookstore.Api: one or two? Second question: what does the user see; is the table drawn once, or does something flash in between? Third: the user sets the threshold to 12 and ticks "Only low stock", then the connection drops for a while and comes back. Is the filter still there? And if they reload the page?
The Double Load: Why It Happens and What the User Sees
An interactive component with prerendering lives as two separate instances. The first runs on the server like Static SSR: the lifecycle runs, the HTML is written to the response, the instance is thrown away. After the browser shows the page, blazor.web.js sets up the circuit and a brand-new instance of the same component is created. The new instance knows nothing about the previous instance's fields; OnInitializedAsync runs from the start. With the inventory page as it was at the end of 05, the panel's log looked like this:
info: InventoryPage: OnInitializedAsync (renderer: Static, interactive: False)
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/inventory
info: InventoryPage: Dispose: stopping the refresh timer
info: InventoryPage: OnInitializedAsync (renderer: Server, interactive: True)
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/inventory
info: InventoryPage: OnAfterRender(firstRender: true): starting the refresh timer
The answer to the first question: two requests. The LogicalHandler lines are the typed HttpClient's own log; each one is a GET to Bookstore.Api. The Dispose in between is the moment the prerendered instance is thrown away. For the second question I put a MutationObserver on the page in Playwright and recorded the page's state every time the DOM changed:
1 ms table (Refreshed at 03:23:38 UTC)
33 ms Loading...
35 ms table (Refreshed at 03:23:38 UTC)
The user first sees the prerendered table. Once the circuit is up, the interactive instance does its first render while stock is still null, and the table is replaced by "Loading..."; when the API answers, the table comes back. On my machine the API runs on the same computer, so this gap took 2 ms and is hard to spot. On a real network the gap is as long as the API's response time; with a 300 ms API the user clearly sees the table disappear and come back. And if the data changes between the two calls, the user sees two different tables on the same page. There is a cost side too: every page view puts twice the load on the API and the database.
Turning prerendering off (new InteractiveServerRenderMode(prerender: false)) ends the double load, but the first response has no content; as we saw in post 04, the user looks at an empty page until the circuit is up. The right fix is to carry the data loaded during prerendering over to the interactive instance.
Carrying the Data with [PersistentState]
In .NET 10 a single attribute is enough:
@code {
private System.Timers.Timer? timer;
// Filled during prerendering and handed to the interactive instance; reloaded after a circuit resumes.
[PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
public List<StockView>? Stock { get; set; }
[PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
public DateTime RefreshedAt { get; set; }
// What the user chose; survives a circuit pause and resume.
[PersistentState]
public InventoryFilter? Filter { get; set; }
protected override async Task OnInitializedAsync()
{
Filter ??= new();
Logger.LogInformation("OnInitializedAsync (renderer: {Renderer}, interactive: {Interactive}, restored: {Restored})",
RendererInfo.Name, RendererInfo.IsInteractive, Stock is not null);
if (Stock is null)
{
await LoadAsync();
}
}
public properties marked with [PersistentState] are serialized to JSON when prerendering finishes and embedded in the page. When the interactive instance is created, Blazor writes these values back into the properties; this happens before OnInitializedAsync. The only thing left in the code is the Stock is null check: if the value arrived, don't go to the API; if it didn't (no prerendering, or the state was lost), load it. The documentation shows the same thing with the Items ??= await LoadItemsAsync(); idiom. Opening the same page again:
info: InventoryPage: OnInitializedAsync (renderer: Static, interactive: False, restored: False)
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/inventory
info: InventoryPage: Dispose: stopping the refresh timer
info: InventoryPage: OnInitializedAsync (renderer: Server, interactive: True, restored: True)
info: InventoryPage: OnAfterRender(firstRender: true): starting the refresh timer
4 ms table (Refreshed at 03:25:21 UTC)
One request, no "Loading..." in between, and the timestamp stayed the same because RefreshedAt was carried over too. stock and refreshedAt, which used to be private fields, are now public properties, and threshold and onlyLow moved into the Filter object below; the attribute only works on public properties.
Up to .NET 9 we wrote the same thing by hand by injecting the PersistentComponentState service and using a RegisterOnPersisting callback: PersistAsJson with a key, then TryTakeFromJson on the interactive side. That API is still there and is used when you need more control; the attribute is built on the same mechanism.
An Analyzer Warning and a Filter Object
To carry the threshold as well, my first attempt was [PersistentState] public int Threshold { get; set; } = 5;. The build stopped with this error:
src/Bookstore.Admin/Components/Pages/Inventory/InventoryPage.razor(43,16): error BL0009: Property 'Bookstore.Admin.Components.Pages.Inventory.InventoryPage.Threshold' has [PersistentState] and a property initializer. This can be overwritten during parameter binding.
BL0009 is actually a warning; it became an error because the project has TreatWarningsAsErrors on. What it says is this: the property initializer runs when the instance is created, and the persisted value is written later. Which value wins depends on the order and is unclear to whoever reads the code. The fix is to give the default in OnInitializedAsync with ??=. For that the filter became a small class:
namespace Bookstore.Admin.Components.Inventory;
// The inventory page's filter; a plain serializable class so it can be persisted with [PersistentState].
public sealed class InventoryFilter
{
public int Threshold { get; set; } = 5;
public bool OnlyLow { get; set; }
}
The page has a Filter ??= new(); line: a persisted filter if one arrived, the default otherwise. The class's own property initializers are fine; the warning is only about the property marked with [PersistentState] itself.
RestoreBehavior: Which Value Comes Back When
In the code above, Stock and RefreshedAt have RestoreBehavior = RestoreBehavior.SkipLastSnapshot and Filter does not. The reason is the second .NET 10 feature: circuit state persistence. When an InteractiveServer circuit goes away (a long network drop, a tab sent to the background, the app pausing the circuit itself), Blazor keeps the last values of the [PersistentState] properties on the server. When the user comes back, a new circuit is created and the components start with these values. The default storage is MemoryCache: at most 1,000 circuits, for two hours. With more than one server, giving the app a HybridCache switches to distributed storage, with a retention of eight hours.
There are two different needs here. The filter the user chose should come back; the stock data should not come back as it was an hour ago, because books may have arrived at the warehouse in the meantime. SkipLastSnapshot says exactly that: use the value from prerendering, skip the stored value when the circuit is re-created. Its opposite, SkipInitialValue, skips the prerendered value and uses only the one from the circuit's return. A third setting, AllowUpdates, comes up in the enhanced navigation section below.
To pause a circuit by hand there are Blazor.pauseCircuit() and Blazor.resumeCircuit(). In Playwright, with the threshold at 12 and "Only low stock" ticked, I paused, resumed and finally reloaded the page:
inventory before pause: threshold=12, onlyLow=true, rows=2
pauseCircuit() -> true
resumeCircuit() -> true
inventory after resume: threshold=12, onlyLow=true, rows=2
inventory after reload: threshold=5, onlyLow=false, rows=20
info: InventoryPage: Dispose: stopping the refresh timer
info: InventoryPage: OnInitializedAsync (renderer: Server, interactive: True, restored: False)
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/inventory
info: InventoryPage: OnAfterRender(firstRender: true): starting the refresh timer
The answer to the third question: after the connection returns the filter is still there and the table still has two rows. In the log, Dispose shows the old circuit being thrown away, and restored: False shows the stock data was skipped on purpose and read fresh from the API. After the reload, everything went back to the defaults. Circuit state persistence only helps while the same page is alive; a full page reload is a new session. If the user's choice must survive a reload, you need browser storage or a record on the server; we will do that in post 09.
An Interactive Notes Area on the Book Details Page
The book details page has been Static SSR since 05. To leave notes for the warehouse team I added a small interactive area to the page:
@inject StaffNoteStore Store
@inject ILogger<StaffNotes> Logger
<h2>Staff notes</h2>
@if (Notes is null || Notes.Count == 0)
{
<p class="text-muted">No notes yet.</p>
}
else
{
<ul>
@foreach (StaffNote note in Notes)
{
<li>@note.CreatedAtUtc.ToString("yyyy-MM-dd HH:mm") UTC: @note.Text</li>
}
</ul>
}
<textarea class="form-control" rows="3" placeholder="A note for the warehouse team"
@bind="Draft" @bind:event="oninput"></textarea>
<button class="btn btn-secondary mt-2" disabled="@(!RendererInfo.IsInteractive || string.IsNullOrWhiteSpace(Draft))"
@onclick="AddNote">Add note</button>
@code {
[Parameter, EditorRequired]
public string Isbn { get; set; } = "";
[PersistentState]
public List<StaffNote>? Notes { get; set; }
// An unsent note: kept across a circuit pause and resume, lost on a full page reload.
[PersistentState]
public string? Draft { get; set; }
protected override void OnInitialized()
{
Logger.LogInformation("StaffNotes for {Isbn} (renderer: {Renderer}, restored notes: {Restored}, draft: {Draft})",
Isbn, RendererInfo.Name, Notes is not null, Draft ?? "(none)");
Notes ??= [.. Store.GetNotes(Isbn)];
}
private void AddNote()
{
Store.Add(Isbn, Draft!);
Notes = [.. Store.GetNotes(Isbn)];
Draft = null;
}
}
The page uses it with <StaffNotes Isbn="@book.Isbn" @rendermode="InteractiveServer" />; the rest of the page stays Static SSR, and a circuit is set up only for this component. For now the notes live in StaffNoteStore in the panel's memory: a Singleton service, every user sees the same notes, and the notes are gone when the app restarts. A durable place (an endpoint in Bookstore.Api) is not the topic of this post; we will look at when a Singleton is right and when it is dangerous in 09.
Two properties are persisted. Notes prevents the double read. Draft is the note the user has not sent yet; we don't want it to be lost when the circuit pauses and comes back. The button is disabled while !RendererInfo.IsInteractive, so that a button that can't be clicked does not look active in the prerendered HTML. With the same Playwright flow:
book page before pause: notes=1, draft="Ask Grace about the reprint"
book page after resume: notes=1, draft="Ask Grace about the reprint"
book page after reload: notes=1, draft=""
info: StaffNotes: StaffNotes for 9780141439549 (renderer: Server, restored notes: True, draft: (none))
info: StaffNotes: StaffNotes for 9780141439549 (renderer: Server, restored notes: True, draft: Ask Grace about the reprint)
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/books/9780141439549
info: StaffNotes: StaffNotes for 9780141439549 (renderer: Static, restored notes: False, draft: (none))
info: StaffNotes: StaffNotes for 9780141439549 (renderer: Server, restored notes: True, draft: (none))
The first line is the first interactive start, the second is the new circuit after the pause: the draft came back. After the reload the draft is empty but the note is still there, because the note lives in the Singleton store and the draft only in the circuit's state.
State Persisted During Enhanced Navigation
Up to .NET 9, persisted state only helped on the first page load; on a page reached with enhanced navigation the interactive component loaded the data again. According to the .NET 10 release notes, state persisted during enhanced navigation now reaches interactive components too. By default it is read only when the component first appears on the page, so a later enhanced navigation to the same page does not overwrite a form the user has filled in. For read-only data you can turn that on with [PersistentState(AllowUpdates = true)].
I tried two routes: first clicking a book on the Static SSR book list (no circuit yet), then going from the inventory page through the menu to the book list and from there to a book (circuit already running). Both were enhanced navigations; the page was not loaded from scratch:
from the Static SSR book list (no circuit yet):
info: StaffNotes: StaffNotes for 9780143035008 (renderer: Static, restored notes: False, draft: (none))
info: StaffNotes: StaffNotes for 9780143035008 (renderer: Server, restored notes: True, draft: (none))
from the inventory page (circuit already running):
info: StaffNotes: StaffNotes for 9780143035008 (renderer: Static, restored notes: False, draft: (none))
info: StaffNotes: StaffNotes for 9780143035008 (renderer: Server, restored notes: False, draft: (none))
With no circuit yet, the state arrived. With the circuit already running, StaffNotes read the notes again; a run where I gave Notes AllowUpdates = true did not change the result. The response did contain the Blazor-Server-Component-State comment, so the state is sent, but in my scenario it did not reach the new component in the running circuit. I could not verify why. The practical takeaway: don't write your code assuming the state always arrives. The ??= idiom works correctly in both cases; at worst the data is read one more time.
The Security of Persisted State
The state travels inside the page as an HTML comment. In InteractiveServer it looks like <!--Blazor-Server-Component-State:CfDJ8...--> and is encrypted with ASP.NET Core Data Protection; for the 20-row list on the inventory page it is a 4,656-character value, and no book title can be read from it. WebAssembly is different. When I added, as an experiment, a [PersistentState] property holding the books more expensive than 450 TRY to post 04's CartPreview, and decoded the page's Blazor-WebAssembly-Component-State value from base64:
4m+M2KR0TmJkvFMb/ugdceATRSk6LXht1IvuC9imnzk= => [{"isbn":"9780441172719","title":"Dune","price":489.0},{"isbn":"9780140449266","title":"The Count of Monte Cristo","price":459.0},{"isbn":"9781400079988","title":"War and Peace","price":689.0}]
The value is plain JSON. There is no encryption, because the .NET runtime in the browser must be able to decode it; any code that can decode it can read it. InteractiveAuto follows the same rule, since it is Server on the first visit and WebAssembly afterwards. The rule is simple: everything you persist in a WebAssembly or Auto component can be read in the page source. Don't put costs, profit margins or another customer's data there; if you are sending such data to a WebAssembly component at all, the problem starts before persistence.
For state in services there is RegisterPersistentService<T>(renderMode): the service's [PersistentState] properties are carried over just like a component's. The documentation gives an important detail here: on the server the service must be scoped, but in the .Client project the same service must be registered as a singleton. Because the browser-side state manager is a singleton and resolves from the root provider, a scoped registration either throws DirectScopedResolvedFromRootException in Development or produces two separate instances, and the restored value never reaches the instance the components use.
In Production
Persisted state adds to the page's size. The stock list took 4.6 KB for 20 books; with 2,000 books megabytes are added to the HTML, and the page's download time grows along with TTFB. Load only the data visible on the first screen during prerendering; if there is paging or filtering, persist only that page.
Circuit state persistence is on by default and lives in server memory: 1,000 circuits, two hours. You can change these limits with CircuitOptions.PersistedCircuitInMemoryMaxRetained and PersistedCircuitInMemoryRetentionPeriod. If a load balancer sits in front of several instances, the in-memory state is not there when the user lands on another instance; you need to provide distributed storage (Redis, for example) through HybridCache. The circuit itself lives under a separate limit: a circuit whose connection dropped is kept in memory for three minutes by default. In my runs, after closing the browser, the inventory page's 30-second timer kept writing to the log; the closed tab's circuit keeps running for those three minutes. More on this in post 12.
Pausing is a scaling tool. Instead of keeping a circuit on the server while the user is on another tab, you can store the state with Blazor.pauseCircuit() and release the circuit; the documentation uses the visibilitychange event as the example. .NET 11 brings an option that does this automatically; that is in post 13.
Common Pitfalls
Putting [PersistentState] on a private property. The project builds, but the page fails when it opens. When I made Notes in StaffNotes private as an experiment:
System.InvalidOperationException: A public property 'Notes' on component type 'Bookstore.Admin.Components.Books.StaffNotes' with a public getter wasn't found.
at Microsoft.AspNetCore.Components.Infrastructure.PersistentValueProviderComponentSubscription.PropertyGetterFactory(ValueTuple`2 key)
That is why I turned the fields on the inventory page into public properties.
Giving a persisted property an initializer. BL0009 catches it; give the default in the lifecycle with ??=.
Loading without checking. If you write Stock = await Api.GetStockAsync();, you overwrite the state even when it arrived, and the double request is back. Always ??= or an is null check.
Persisting data that must be fresh without a RestoreBehavior. With the default behaviour, when the circuit comes back the stock list returns as it was at the moment of the pause; the user may make decisions looking at counts from hours ago.
Persisting types that can't be serialized. The documentation says objects with cyclic references and ORM entities may not serialize correctly. What you persist should be a plain record or class such as StockView, not an EF Core entity.
A Small Experiment
★☆☆ Remove [PersistentState] from Filter and run Blazor.pauseCircuit() and then Blazor.resumeCircuit() in Playwright or the browser console. What happened to the threshold? What does the OnInitializedAsync line in the log show?
★★☆ Remove the RestoreBehavior from Stock, open the page and pause the circuit, change a book's stock in Bookstore.Api with PUT /api/inventory/{isbn} in the meantime, then resume. Which count does the table show? What happens when the 30-second timer fires?
★★★ Make BookListPage (Static SSR) InteractiveServer for a moment and carry the result list with [PersistentState]. Measure the size of the persisted state in the page source by search term: how much does the HTML grow with page sizes of 5, 20 and 50?
We solved carrying state over, but the stock count form still has no validation; in 05 we caught the API's 400 by hand and showed a message. In the next post we move to Blazor's form infrastructure: EditForm, input components, DataAnnotations and an author and book form with .NET 10's nested validation.