The state of a Blazor app lives in more places than it seems: in fields, in services, in the page that the server sent, and in the memory of a connection that can drop at any time. This lesson follows it through all the transitions, from prerendering to the island, from the server into the browser and across a dropped connection, and says for each what survives.

Where the Kladde stands

The weekly calendar from K2 2.5.8 is a server island under /internal/calendar with [PersistentState] on the week and the appointments; the state services from K2 2.5.11 are Scoped. New here: the calendar gets a filter by keyword (the contacts' Tags), and the week and the filter should survive prerendering and a dropped connection. Keep your own names and carry the steps over.

Double loading and what the page carries

Why an interactive component loads twice is in K2 2.5.8. New is a look at where the saved state lies: as a comment at the end of the prerendered page, in very different ways for Server and WebAssembly.

In this lesson's throwaway project, the calendar calls its source exactly once during prerendering (a test counts the calls), and the page carries the comment <!--Blazor-Server-Component-State:…-->, whose value begins with CfDJ8, the mark of encrypted Data Protection text; the content of the appointment cannot be found in it. With a WebAssembly page, there is instead <!--Blazor-WebAssembly-Component-State:…-->, and the value is Base64 of JSON (section "What must never reach the browser"). According to Learn, with Interactive Server ASP.NET Core Data Protection protects the data; with WebAssembly it is open to the browser and must not contain anything sensitive. InteractiveAuto counts as WebAssembly.

[PersistentState] and its options

The rules of the attribute from Learn, in the order in which they bite:

  • Public properties. The framework finds them through reflection, also for trimming and source generators; private does not work.
  • null means "not there". The component checks with ??= or is null whether it has to load.
  • System.Text.Json with default settings; not trim-safe, the types have to be preserved (W8.3.1).
  • Several components of the same type need @key.
  • Ways of your own. PersistentComponentStateSerializer<T> replaces the JSON for a type; the service PersistentComponentState offers RegisterOnPersisting and RegisterOnRestoring.

Since .NET 10, the attribute has two options:

C#
// Read-only data that may change with each enhanced navigation to the same page.
[PersistentState(AllowUpdates = true)]
public WeatherForecast[]? Forecasts { get; set; }

// Not restored when the circuit first starts after prerendering.
[PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)]
public string? NoPrerenderedData { get; set; }

// Not restored after a reconnect: fresh data once the connection is back.
[PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
public int CounterNotRestoredOnReconnect { get; set; }

AllowUpdates allows updating the state also on an enhanced navigation to the same page; otherwise a component only loads it when it first appears, so that input is not overwritten. It belongs on data that you only read. SkipLastSnapshot is the answer to the question whether, after a drop, the old data or fresh data should come: a person's filter belongs to them and should come back, the list of appointments should be read again.

For the value, there is a limit that Learn names specifically: a prerendered state that is too large can exceed SignalR's message limit (the default of MaximumReceiveMessageSize is 32 KB). Then the circuit does not start ("Circuit host not initialized"). Learn advises persisting less, and warns that a higher limit increases the risk of denial-of-service attacks. Persist keys and input, not whole tables.

State services: RegisterPersistentService

State that lives in a service (like the filter that several components of the calendar share) gets the same attribute on the service's public property and a registration with the mode in which the service is needed. CalendarState.cs of the throwaway project:

C#
public sealed class CalendarState
{
    [PersistentState]
    public string? TagFilter { get; set; }

    [PersistentState]
    public DateOnly? Monday { get; set; }
}
C#
builder.Services.AddScoped<CalendarState>();

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents()
    .RegisterPersistentService<CalendarState>(RenderMode.InteractiveServer);

Learn names RenderMode.Server, RenderMode.Webassembly and RenderMode.InteractiveAuto (both worlds) as values; in the throwaway project, RenderMode.InteractiveServer accepted the registration. On the server, only Scoped services are persisted. In WebAssembly, the same service has to be registered in the .Client project as a singleton: the state manager in the browser gets its dependencies from the root provider, and a Scoped service would give two instances, of which the components use the unfilled one.

What must never reach the browser

In the throwaway project, the prerendered WebAssembly page /internal/contacts carried four entries in the comment, each value Base64 of JSON. A test decrypted them, using only Convert.FromBase64String and System.Text.Json:

[{"id":1,"name":"Anna Beispiel","company":"Sample Inc."}]
{"claims":[{"type":".../claims/name","value":"Ada"},{"type":".../claims/role","value":"Staff"}],"nameClaimType":"...","roleClaimType":"..."}
[{"assembly":"Kladde.Web","fullTypeName":"Kladde.Web.CalendarState"},{"assembly":"Microsoft.AspNetCore.Components.Endpoints", ... }]
{"value":"CfDJ8…","formFieldName":"__RequestVerificationToken"}

(The claim types are shortened.) These are the state of the page, the sign-in state (only name and role, W8.2.1), the list of persisted services and the framework's anti-forgery token, all readable by anyone who opens the page. A rule follows for every property with [PersistentState] in a WebAssembly or Auto component: Everything that is there belongs to the browser. What does not belong there: access tokens and secrets (they stay on the server), data of other people, internal fields of a record (hence ContactResponse with three fields instead of the entity) and data that only certain roles may see. What a component reads from a service during prerendering is in the page before the browser even asks; the permissions belong in the API (W8.2.1).

The life of a circuit

A circuit comes into being when the first server component of a page is rendered, and lives until the page is closed. Every tab is a session of its own with a circuit of its own. In between, the connection can drop, and Learn describes three outcomes:

  1. Reconnected. After a short drop, blazor.web.js reconnects to the circuit that is still standing on the server, including its state. According to the defaults of CircuitOptions, the server keeps disconnected circuits for at most 3 minutes and at most 100 of them.
  2. Rejected. The server is reachable but no longer knows the circuit: it was gone too long, the server was restarted, the process recycled or the circuit crashed. The state in memory is gone; Learn compares this to closing and opening a desktop app.
  3. Failed. The connection could not be established, probably a network error; the page can try again.

With several servers, one more thing comes in: the circuit lives in the memory of exactly one server, and the reconnection has to go back there (sticky sessions, W8.3.3). The template brings the display for it: the component ReconnectModal with a stylesheet and a script. Learn describes the states that it reports as CSS classes and as the event components-reconnect-state-changed:

State (detail.state) Meaning Usual response
show connection lost, the display appears open the dialog
retrying a new attempt is running show a counter
hide the connection is back close the dialog
failed reconnecting failed call Blazor.reconnect()
rejected the server rejects, the state there is gone location.reload()
paused the circuit is paused offer to resume: Blazor.resumeCircuit()

Because a script comes with it and JavaScript in the Kladde is TypeScript, it looks like this, reconnect.ts (compiled strictly):

TypeScript
type ReconnectState = "show" | "hide" | "retrying" | "failed" | "rejected" | "paused";

interface BlazorApi {
  reconnect(): Promise<boolean>;
  resumeCircuit(): Promise<boolean>;
}

declare const Blazor: BlazorApi;

export function watchReconnect(modal: HTMLDialogElement): void {
  modal.addEventListener("components-reconnect-state-changed", (event) => {
    const state = (event as CustomEvent<{ state: ReconnectState }>).detail.state;
    switch (state) {
      case "show":
        modal.showModal();
        break;
      case "hide":
        modal.close();
        break;
      case "failed":
        void Blazor.reconnect();
        break;
      case "rejected":
        location.reload();
        break;
      case "paused":
        void Blazor.resumeCircuit();
        break;
    }
  });
}

After rejected, the page reloads and starts with fresh state: whatever should still be there then has to live outside the memory of this connection. The number and spacing of the attempts are set by Blazor.start({ circuit: { reconnectionOptions: { maxRetries: 3, retryIntervalMilliseconds: 2000 } } }) (with autostart="false" on the script). According to Learn, the default staggering is an implementation detail that can change without notice. When the display appears depends on SignalR's timings (defaults: ClientTimeoutInterval 30 seconds, KeepAliveInterval 15 seconds); according to Learn, timeouts should be at least double the keep-alive interval.

Circuit state (.NET 10)

New in .NET 10: a session can survive a long drop or be paused on purpose, as long as the page is not reloaded. It is meant for throttled tabs, app switches on the phone, network interruptions and freeing up resources: according to Learn, a disconnected circuit keeps needing memory, while saved state needs only a fixed amount. The conditions according to Learn:

  • It only works with Interactive Server. If the person reloads the page, the state is gone.
  • The state has to be JSON-serializable; cycles and ORM entities may not serialize correctly.
  • Components in loops need @key.
  • Persist only what is needed. There is no guarantee: if it fails, the usual behavior after a drop remains.
  • Only app state can be persisted, never the UI itself; you model the expanded node of a tree as serializable state.

The default is MemoryCache for a single app instance with up to 1,000 remembered circuits for two hours. CircuitOptions changes both:

C#
builder.Services.Configure<CircuitOptions>(options =>
{
    options.PersistedCircuitInMemoryMaxRetained = 2000;
    options.PersistedCircuitInMemoryRetentionPeriod = TimeSpan.FromHours(3);
});

For several instances, Learn names a HybridCache as the distributed store, with the retention PersistedCircuitDistributedRetentionPeriod (default eight hours); whether the circuit is resumed over it after an app restart is not shown here (open). Pausing on purpose works in the browser with Blazor.pauseCircuit() and Blazor.resumeCircuit(), for example when the page is hidden (visibilitychange, as in Learn). Pausing stores the state in the browser's memory and frees the circuit on the server; resuming builds a new circuit from this state.

Lifetime of services per mode

Where a state lives depends on the lifetime of the service. The frame from Learn:

Lifetime Static SSR Interactive Server WebAssembly
Scoped one request one circuit (one tab) like Singleton: there are no scopes
Singleton the whole app the whole app, all people one browser tab
Transient per request for the service per request for the service per request for the service
  • A singleton on the server is shared by all people. Learn advises against sharing state through singletons except with the utmost care, because state can leak between circuits. A memory cache with a key that users do not control is acceptable. Per-person state belongs in Scoped.
  • New circuit, new instance. A new window, a new tab and a reload create a new circuit and with it new Scoped services.
  • Transient and IDisposable. The container holds such services until the end of the circuit, a memory leak; do not use them. OwningComponentBase supplies a Scoped service with the lifetime of a component.
  • Database. In an island, a Scoped DbContext would be the same across the whole connection; K2 2.5.8 therefore uses a factory.

What prerendering means for Scoped is not described specifically by Learn; we secure everything that should survive with [PersistentState] (open: not measured).

The Kladde: week and filter survive

The page only loads if nothing is there, and reads the appointments again after a drop (SkipLastSnapshot), while the week and the filter stay in the service, Calendar.razor of the throwaway project:

Razor
@page "/internal/calendar"
@attribute [Authorize(Roles = "Staff,Admin")]
@rendermode InteractiveServer
@inject IWeekSource Source
@inject CalendarState State

@code {
    // After a reconnect the entries are read again; the filter and the week come back from the snapshot.
    [PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
    public List<CalendarEntry>? Entries { get; set; }

    protected override async Task OnInitializedAsync()
    {
        State.Monday ??= new DateOnly(2025, 3, 3);
        Entries ??= await Source.LoadAsync(State.Monday.Value, State.TagFilter, CancellationToken.None);
    }
}

Exercise 1: Where is the state now?

The calendar is built as above, the filter is set to "Consultation". Predict whether the filter is still "Consultation" afterward.

  1. The person presses F5.
  2. The Wi-Fi drops for 20 seconds.
  3. The person opens the calendar in a second tab.
  4. The server is restarted, the person waits with the tab open.
  5. Someone changes CalendarState from Scoped to Singleton so that the filter arrives "everywhere".

Solution 1

  1. No. Reloading closes the old circuit and starts a new one; circuit state only holds as long as the page is not reloaded. If the filter should survive a reload, it belongs in the address or in the browser's storage.
  2. Yes, if the connection is back within the period (up to 3 minutes with the defaults): the same circuit, the same service.
  3. No, a second tab is a second circuit with a service of its own.
  4. The server's memory is gone: reconnecting ends with "rejected", and the page reloads. The filter only stays if it lives outside the memory, for example in a distributed store (HybridCache, not run, open).
  5. All people now share the filter: what one chooses, the other sees. That is the leak that Learn warns about.

Exercise 2: State in plain text and encrypted

In a throwaway project, create CalendarState and Calendar.razor, register the service with RegisterPersistentService and write a test with WebApplicationFactory (a development sign-in is enough; W8.2.1 builds the proper one). It should check: (a) The source is called exactly once when /internal/calendar is fetched. (b) The comment Blazor-Server-Component-State does not contain the appointment's title. Then predict what (b) would give with a WebAssembly page, and check it with a second page.

Solution 2

C#
string html = await client.GetStringAsync("/internal/calendar");

((CountingWeekSource)factory.Services.GetRequiredService<IWeekSource>()).Calls.ShouldBe(1);
string? state = StateComment(html, "Server");
state.ShouldNotBeNull();
state.ShouldNotContain("Consultation");

StateComment uses a regular expression to get the text between <!--Blazor-Server-Component-State: and -->. With a WebAssembly page, the value is Base64 of JSON whose values are again Base64 of JSON; after decoding, the contact's name is in plain text. If a word is in the comment that does not belong there, that is a design error: either the page is the wrong place for it, or the data belongs behind an API with permissions.

Status of verification. A throwaway project outside the repository was built and run with the .NET SDK 10.0.400 on net10.0 (server, .Client, test project with xunit, Shouldly, bUnit and WebApplicationFactory); dotnet test ended with exit code 0 (Passed! - Failed: 0, Passed: 20, Skipped: 0, Total: 20). What was seen: CalendarState with RegisterPersistentService<CalendarState>(RenderMode.InteractiveServer) and [PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)] compile; a request to /internal/calendar calls the source once and delivers the comment Blazor-Server-Component-State, which begins with CfDJ8 and does not contain the title; the WebAssembly page delivered four entries that could be decoded with Base64 and JSON (contact list, name and role, list of persisted services, anti-forgery token). The options of [PersistentState] and the configuration of CircuitOptions were compiled; reconnect.ts was compiled strictly with TypeScript 5.9.3 (exit code 0); pauseCircuit and resumeCircuit appear only in the text. Not run: a browser, a real connection, a drop, reconnecting, circuit state, HybridCache, pausing and resuming, the restoration on the interactive page (the test only sees the prerendered part) and Azure. Periods and limits come from Microsoft Learn. Open: Scoped during prerendering, HybridCache after a restart, the names RenderMode.Server and RenderMode.Webassembly in Learn versus RenderMode.InteractiveServer in the code.

What you can do now

  • You name for every transition which state survives: prerendering, switching to WebAssembly, a drop, a reload, a restart.
  • You use [PersistentState] with AllowUpdates and RestoreBehavior and persist state services with RegisterPersistentService.
  • You know that with WebAssembly the state is in plain text in the page, and you keep tokens, secrets and other people's data out.
  • You describe reconnected, rejected and failed and drive the display in TypeScript.
  • You place circuit state and choose the lifetime of services per mode.

The Kladde: the calendar's week and filter survive prerendering, and after a drop the calendar reads its appointments again. Next comes sign-in, which arrives differently in these modes.

Further reading