Every Blazor developer who has enabled prerendering has hit the same wall. Your component loads, fetches data from an API, renders beautifully on the server — and then the entire thing re-executes on the client, flashing the UI as it throws away the prerendered state and builds everything from scratch. You watch your OnInitializedAsync fire twice, your HTTP calls double, and your users see a flicker that makes the whole experience feel broken.
For years, the fix was the PersistentComponentState service: inject it, register a callback, serialise your state manually, deserialise it on the other side, and remember to dispose the subscription. It worked, but it was tedious enough that many teams simply disabled prerendering instead. .NET 10 changes this with the [PersistentState] attribute — a declarative, single-line solution that replaces all that ceremony.
The double-render problem
To understand why [PersistentState] matters, you need to understand what happens during prerendering. When a request arrives, Blazor renders your component on the server to produce HTML. This triggers OnInitializedAsync, which might call an API, query a database, or compute expensive results. The server sends that HTML to the browser immediately.
Then the client-side runtime boots. It creates the same component again and runs OnInitializedAsync a second time. Whatever state you had from the server render is gone — the component starts fresh. If your initialisation involves a random value, a timestamp, or any non-deterministic data, the prerendered content visibly snaps to a different value. If it involves an HTTP call, you've just doubled your API traffic for every page load.
@page "/dashboard"
@inject IDashboardService DashboardService
<h1>Dashboard</h1>
@if (metrics is null)
{
<p>Loading...</p>
}
else
{
<MetricsGrid Data="@metrics" />
}
@code {
private DashboardMetrics? metrics;
protected override async Task OnInitializedAsync()
{
// This runs TWICE: once on the server, once on the client.
// The server result is thrown away before the client ever sees it.
metrics = await DashboardService.GetMetricsAsync();
}
}
The user sees the metrics appear, then a loading spinner, then the metrics again. It is not a great first impression.
The old way: manual state persistence
Before .NET 10, you solved this by injecting PersistentComponentState and wiring up the serialisation yourself:
@page "/dashboard"
@implements IDisposable
@inject IDashboardService DashboardService
@inject PersistentComponentState ApplicationState
<h1>Dashboard</h1>
@if (metrics is null)
{
<p>Loading...</p>
}
else
{
<MetricsGrid Data="@metrics" />
}
@code {
private DashboardMetrics? metrics;
private PersistingComponentStateSubscription subscription;
protected override async Task OnInitializedAsync()
{
if (!ApplicationState.TryTakeFromJson<DashboardMetrics>(
nameof(metrics), out var restored))
{
metrics = await DashboardService.GetMetricsAsync();
}
else
{
metrics = restored;
}
subscription = ApplicationState.RegisterOnPersisting(PersistState);
}
private Task PersistState()
{
ApplicationState.PersistAsJson(nameof(metrics), metrics);
return Task.CompletedTask;
}
public void Dispose() => subscription.Dispose();
}
That is roughly 20 extra lines for a single property. Multiply it across every component that fetches data during initialisation and the boilerplate becomes a real maintenance burden. You also need to remember IDisposable, use consistent keys, and handle the TryTakeFromJson pattern correctly every time.
The new way: [PersistentState]
.NET 10 reduces the entire pattern to a single attribute:
@page "/dashboard"
@inject IDashboardService DashboardService
<h1>Dashboard</h1>
@if (Metrics is null)
{
<p>Loading...</p>
}
else
{
<MetricsGrid Data="@Metrics" />
}
@code {
[PersistentState]
public DashboardMetrics? Metrics { get; set; }
protected override async Task OnInitializedAsync()
{
Metrics ??= await DashboardService.GetMetricsAsync();
}
}
That is the entire implementation. The framework serialises Metrics during prerendering, embeds it in the response, and restores it before OnInitializedAsync runs on the client. The null-coalescing assignment (??=) means the API call only happens if the state was not restored — which, during normal prerendering, means it only runs once on the server.
A few rules to note:
- The property must be
public. The framework uses reflection for serialisation, trimming, and source generation. - Properties are serialised with
System.Text.Jsonusing default settings. - During client-side rendering (
InteractiveWebAssembly), the persisted state is embedded in the HTML and visible in the browser. Do not persist sensitive data unless you are usingInteractiveServer, which encrypts the payload with ASP.NET Core Data Protection.
Persisting state across multiple component instances
When you have several instances of the same component on a page — say, a list of expandable cards — each instance needs its state associated with the correct instance. Use the @key directive to ensure the framework maps persisted state to the right component:
<div class="order-card">
<h3>Order #@Order.Number</h3>
<p>Status: @Order.Status</p>
<p>Items: @Details?.ItemCount</p>
</div>
@code {
[Parameter, EditorRequired]
public OrderSummary Order { get; set; } = default!;
[PersistentState]
public OrderDetails? Details { get; set; }
protected override async Task OnInitializedAsync()
{
Details ??= await OrderService.GetDetailsAsync(Order.Id);
}
}
@page "/orders"
@foreach (var order in orders)
{
<OrderCard @key="order.Id" Order="@order" />
}
Without @key, the framework cannot reliably associate each component's persisted state with the correct instance, and you may see data appear on the wrong card after hydration.
Service-level state persistence
The [PersistentState] attribute is not limited to components. You can apply it to properties on a DI service, which is useful when multiple components share the same state through an injected service.
public class CartTracker
{
[PersistentState]
public List<CartItem> Items { get; set; } = [];
[PersistentState]
public decimal Total { get; set; }
public void AddItem(CartItem item)
{
Items.Add(item);
Total += item.Price;
}
}
Register the service as scoped and then register it for persistence with RegisterPersistentService, specifying which render modes it should support:
builder.Services.AddScoped<CartTracker>();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents()
.RegisterPersistentService<CartTracker>(RenderMode.InteractiveAuto);
The render mode argument tells Blazor when to activate persistence for this service. RenderMode.InteractiveAuto covers both Server and WebAssembly render modes. If your app only uses one, pass RenderMode.Server or RenderMode.Webassembly instead.
// NOTE
Only scoped services are supported. Singleton services cannot be registered for persistence because their lifetime does not align with the component tree's serialisation cycle.
Enhanced navigation and AllowUpdates
By default, [PersistentState] only restores state when a component first loads. If a user navigates to the same page via enhanced navigation (an in-app link that does not trigger a full page reload), the persisted state is not re-applied. This is intentional — it prevents incoming state from overwriting data the user has already edited in a form, for example.
But some data is read-only and expensive to fetch. Weather forecasts, product catalogues, reference data — these benefit from being refreshed on each enhanced navigation. For those cases, opt in with AllowUpdates:
[PersistentState(AllowUpdates = true)]
public WeatherForecast[]? Forecasts { get; set; }
protected override async Task OnInitializedAsync()
{
Forecasts ??= await ForecastService.GetForecastAsync();
}
With AllowUpdates = true, the component picks up freshly persisted state every time enhanced navigation brings the user to the page, even if the component is already mounted.
Controlling restore behaviour
.NET 10 introduces RestoreBehavior to give you fine-grained control over when state restoration kicks in. Two options are available:
SkipInitialValue — skips restoring state during the initial prerender-to-interactive handoff. Useful if you want the component to always fetch fresh data on first load but still benefit from persistence during reconnection:
[PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)]
public string AlwaysFreshOnFirstLoad { get; set; } = string.Empty;
SkipLastSnapshot — skips restoring state after a circuit reconnection, ensuring the user always gets fresh data when they come back after a connection drop:
[PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
public int CounterNotRestoredOnReconnect { get; set; }
These options address different use cases. SkipInitialValue is for data that should never be stale on first visit. SkipLastSnapshot is for data where a reconnection should trigger a refresh rather than restoring potentially outdated state.
Circuit state persistence
The [PersistentState] attribute ties into a broader .NET 10 feature: circuit state persistence for Blazor Server. In previous versions, if a user's SignalR connection dropped and the circuit was evicted, all component state was lost. The user would see a "reconnecting" overlay, followed by a full page reload that wiped everything.
.NET 10 changes this. When a circuit is about to be evicted, Blazor persists any properties marked with [PersistentState]. When the user reconnects, a new circuit is created and the persisted values are restored, allowing users to continue where they left off.
The state is encrypted using ASP.NET Core's Data Protection APIs and stored in memory by default. For apps running across multiple server instances, you can configure HybridCache as the backing store to persist state to a distributed cache:
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new()
{
Expiration = TimeSpan.FromMinutes(30)
};
});
You can also control circuit pausing and resumption from JavaScript, which is useful for scenarios like saving state before the user switches tabs on a mobile device:
// Pause the circuit before the connection drops
Blazor.pause();
// Resume and restore persisted state when the user returns
Blazor.resume();
// WARNING
Circuit state persistence is best-effort. It is not guaranteed that the state will be recoverable — a server restart, cache eviction, or data protection key rotation can all invalidate persisted state. Design your components to handle a missing state gracefully, which is exactly what the ??= pattern already does.
Custom serialisers
If System.Text.Json with default settings does not suit your needs — perhaps you need custom converters, specific naming policies, or a completely different serialisation format — you can register a custom serialiser:
public class UserProfileSerialiser
: PersistentComponentStateSerializer<UserProfile>
{
public override byte[] Persist(UserProfile value)
{
// Custom serialisation logic
return JsonSerializer.SerializeToUtf8Bytes(value, CustomOptions);
}
public override UserProfile Restore(byte[] data)
{
return JsonSerializer.Deserialize<UserProfile>(data, CustomOptions)!;
}
private static readonly JsonSerializerOptions CustomOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
Register it as a singleton:
builder.Services.AddSingleton<
PersistentComponentStateSerializer<UserProfile>,
UserProfileSerialiser>();
The framework will use your serialiser for any [PersistentState] property of type UserProfile, falling back to the default JSON serialiser for all other types.
Common pitfalls
Forgetting the null check pattern. The ??= operator is the key to making [PersistentState] work correctly. Without it, you unconditionally overwrite the restored value:
// Bad — always fetches, ignoring restored state
[PersistentState]
public ProductList? Products { get; set; }
protected override async Task OnInitializedAsync()
{
Products = await CatalogService.GetProductsAsync();
}
// Good — only fetches if state was not restored
protected override async Task OnInitializedAsync()
{
Products ??= await CatalogService.GetProductsAsync();
}
Persisting sensitive data in WebAssembly render modes. When using InteractiveWebAssembly or InteractiveAuto, the persisted state is embedded in the HTML response as plain (non-encrypted) data. Authentication tokens, personal identifiable information, or any data you would not want in the browser's page source should not be persisted in these modes. InteractiveServer encrypts the payload, but WebAssembly does not.
Non-public properties. The attribute only works on public properties. A private or internal property with [PersistentState] will silently do nothing — no compile-time warning, no runtime error, just no persistence.
Missing @key on repeated component instances. Without @key, the framework cannot reliably associate persisted state with the correct component instance. You might see data restored to the wrong instance, or state that appears missing entirely.
Using the attribute on transient or singleton services. RegisterPersistentService only supports scoped services. Attempting to use it with a transient or singleton service will not produce the expected behaviour — the persistence hooks are tied to the scoped DI container that aligns with the component tree's lifetime.
Mixing the old and new approaches. The [PersistentState] attribute and the imperative PersistentComponentState.RegisterOnPersisting API can coexist in the same application, but avoid using both approaches for the same property in the same component. Pick one and stick with it.
Summary
- The
[PersistentState]attribute replaces the manualPersistentComponentStateboilerplate with a single-line declaration onpublicproperties. - Use
??=inOnInitializedAsyncto skip data fetching when state has been restored. - Apply
@keyto repeated component instances to ensure state maps to the correct instance. - Register services for persistence with
RegisterPersistentServiceto share persisted state across components via DI. - Set
AllowUpdates = truefor read-only data that should refresh on enhanced navigation. - Use
RestoreBehaviorto control whether state is restored during prerendering, reconnection, or both. - Circuit state persistence in Blazor Server means users can recover from connection drops without losing their work.
- Never persist sensitive data in WebAssembly render modes — the payload is embedded in the HTML and visible to the browser.