Eliminate Double‑Fetch in Blazor Prerendering with PersistentComponentState
Blazor Server and Blazor Auto both prerender: the server renders your component to HTML so the user sees content immediately. Then the interactive runtime boots, your component rehydrates, and OnInitializedAsync runs again.
If your component loads data from an API, you now have:
- A prerender fetch
- A second fetch during interactive boot
- A flash of “Loading…” even though the data was already on the page
- Double the cost for metered API calls
It's a tiny detail with outsized impact on UX and backend load. Fortunately, the fix is clean and in .NET 10, it’s almost effortless.
The Problem
Let’s say you’re building a simple library catalog. On first load, you fetch a list of books:
@code {
private Book[]? _books;
protected override async Task OnInitializedAsync()
{
// Runs twice: prerender + interactive
_books = await Http.GetFromJsonAsync<Book[]>("/api/books");
}
}
What the user sees:
- Fully rendered HTML with the book list
- Interactive boot
_booksresets to null- “Loading…” flashes
- API call fires again
It is wasteful, distracting, and unnecessary.
The Initial Fix: PersistentComponentState (Pre-.NET 10)
Before .NET 10, the fix was to explicitly stash prerendered data and restore it during interactive boot.
Here's the same library catalog, but using PersistentComponentState:
@inject PersistentComponentState AppState
@implements IDisposable
@code {
private Book[]? _books;
private PersistingComponentStateSubscription? _subscription;
protected override async Task OnInitializedAsync()
{
_subscription = AppState.RegisterOnPersisting(PersistAsync);
if (!AppState.TryTakeFromJson("books", out Book[]? restored))
{
restored = await Http.GetFromJsonAsync<Book[]>("/api/books");
}
_books = restored;
}
private Task PersistAsync()
{
AppState.PersistAsJson("books", _books);
return Task.CompletedTask;
}
public void Dispose() => _subscription?.Dispose();
}
Prerender pass:
TryTakeFromJson returns false → fetch runs → Blazor serializes the data into the HTML.
Interactive pass:
TryTakeFromJson returns true → data restored → no second fetch.
It works, but there's a lot of boilerplate code needed to support this behavior.
The .NET 10 Fix: [PersistentState]
.NET 10 finally gives us the ergonomic version: a single attribute that handles serialization, persistence, and restoration.
@code {
[PersistentState]
public Book[]? Books { get; set; }
protected override async Task OnInitializedAsync()
{
Books ??= await Http.GetFromJsonAsync<Book[]>("/api/books");
}
}
That's the entire fix.
No subscriptions.
No disposal.
No boilerplate.
The ??= ensures the fetch only runs when the prerender pass didn't already supply the data.
A More Realistic Example: Searching + Paging Books
Here is a more "real app" example: a searchable, paginated book list that avoids double-fetching.
@page "/library"
@code {
[PersistentState]
public LibraryState? State { get; set; }
private string _query = "";
private int _page = 1;
protected override async Task OnInitializedAsync()
{
if (State is null)
{
State = await LoadAsync(_query, _page);
}
}
private async Task OnSearchChanged(string query)
{
_query = query;
State = await LoadAsync(_query, _page);
}
private Task<LibraryState> LoadAsync(string query, int page)
=> Http.GetFromJsonAsync<LibraryState>($"/api/library?q={query}&p={page}")!;
}
public record LibraryState(Book[] Books, int TotalCount);
public record Book(string Title, string Author, int Year);
This pattern scales cleanly: hydrate once, reuse everywhere.
Practical Considerations
Keep the payload small.
The state is embedded as JSON in the prerendered HTML. A few dozen books? Fine. Thousands? Probably not.Keys must be unique.
Each[PersistentState]property name becomes a key. Collisions cause subtle bugs.Not a cache.
This is a handoff mechanism, not a persistence layer. It does not survive navigation or reloads.
If you need durable caching, look at local storage or a proper state container.
Conclusion
If your Blazor components flash "Loading…" after prerendering, or if your API calls mysteriously run twice, you are hitting the prerender handoff gap.
PersistentComponentState fixes it.
.NET 10 makes it trivial with [PersistentState].
This is one of those small technical details that dramatically improves perceived performance and reduces backend load. It is worth adopting everywhere you prerender.