Blazor SSR State: TempData, Session and CacheView
Objective
A static SSR page forgets everything when the response leaves: no circuit, no
component instance, no fields. Yet a real page needs a "saved" message after a
redirect, a cart that survives a click, a filter that survives a reload. This
concept compares the five places that state can live in a static SSR Blazor app
(the query string, the posted form, TempData, the session and the output of a
cached block), and what .NET 11 added: [SupplyParameterFromTempData],
[SupplyParameterFromSession], ITempData and the CacheView component. Everything
below was run on the .NET 11.0.100 GA with WebApplicationFactory, reading the HTML
and the Set-Cookie headers. No browser was involved, so client-only behavior is
attributed to the documentation.
Use Cases
- A "saved" message after Post/Redirect/Get, shown once and gone on refresh.
- A cart or a wizard step kept per visitor without a database row.
- A slow, shared fragment (a news box, a menu) rendered once and served from memory for the next requests.
- Choosing between a cookie and the server for state, and knowing what each costs.
Deep Dive
Five places, one question: who owns the value and for how long
| Mechanism | Lives in | Lifetime | Typical use |
|---|---|---|---|
Query string, [SupplyParameterFromQuery] |
the URL | as long as the link | filters, paging |
Posted form, [SupplyParameterFromForm] |
the request body | one request | the submit itself |
| TempData | a cookie (or the session) | until read once | flash message after a redirect |
| Session | server memory plus an id cookie | until it expires | cart, wizard |
CacheView |
server memory, shared | until it expires | an expensive fragment |
[PersistentState] is not in the table on purpose: it carries a value from the
prerender to the interactive instance of the same page, which is a different
problem. See blazor-data-access-and-streaming-rendering. The form and query rows
are covered in blazor-static-ssr-and-enhanced-navigation and
blazor-routing-layouts-and-navigation; the rest of this concept is about the three
new ones.
TempData: a flash message across the redirect
The page below saves in the POST handler and redirects to itself. The property is filled from TempData on the next GET:
plaintext@page "/flash" @inject NavigationManager Nav <p id="message">@Shown</p> <EditForm Model="Model" FormName="flash" OnValidSubmit="Submit" Enhance> <InputText @bind-Value="Model.Text" /> <button type="submit">Save</button> </EditForm> @code { [SupplyParameterFromTempData] private string? Message { get; set; } [SupplyParameterFromForm] private FlashModel Model { get; set; } = default!; private string? Shown; protected override void OnInitialized() { Model ??= new(); Shown = Message; Message = null; // consume it, see below } private void Submit() { Message = $"saved {Model.Text}"; Nav.NavigateTo("flash"); } }
Posting Text=hello with curl-style requests through WebApplicationFactory:
textPOST /flash 302 Location: /flash Set-Cookie: .AspNetCore.Components.TempData=CfDJ8...; path=/; samesite=lax; httponly GET /flash <p id="message">saved hello</p> GET /flash <p id="message"></p>
The cookie is HttpOnly, SameSite=Lax and its value starts with CfDJ8, the prefix of
a Data Protection payload, so TempData rides on the key ring described in
data-protection-key-ring-and-purposes (in a farm, every node needs the same ring).
A second client, with its own cookie jar, saw an empty message: the value belongs to
one browser.
Two details cost me time. First, reading the property does not delete the value:
in my first version, without Message = null, three GETs in a row all showed
saved a. Assigning null after reading is what makes it one-shot with the
attribute. Second, the ITempData service (a cascading parameter) works the other
way around, and that is the API that has the one-shot rule built in:
plaintext[CascadingParameter] public ITempData? TempData { get; set; } // Peek("flash") reads without removing // Get("flash") reads and schedules the removal // Keep() / Keep("flash") keeps for one more request
Run: after the handler wrote TempData["flash"] = "manual x" and redirected, the
first GET read manual x with both Peek and Get, the second GET read nothing
and the response cleared the cookie (expires=Thu, 01 Jan 1970). Choose the
attribute for the simple case, ITempData when you need Peek or Keep.
Where TempData is stored is a setting: RazorComponentsServiceOptions.TempDataProviderType
accepts Cookie (the default) and SessionStorage, and TempDataCookie shapes the
cookie. I switched to SessionStorage and the POST response no longer carried the
TempData cookie; the value lived in the session under the key __BlazorTempData as
{"flash":{"type":"System.String","value":"manual x"}}. The cookie option keeps
the server stateless and is bounded by the browser's cookie size; the session
option needs a session store that every node can reach.
Also note the compiler warning I got while writing the page: BL0008 says that
[SupplyParameterFromForm] plus a property initializer can be overwritten with
null on a post. Use default! and Model ??= new() in OnInitialized, as above.
Session: the cart that survives
[SupplyParameterFromSession] reads and writes a value in HttpContext.Session. It
needs the session services and middleware, which are not part of
AddRazorComponents():
csharpbuilder.Services.AddDistributedMemoryCache();
builder.Services.AddSession();
// ...
app.UseSession();
app.MapRazorComponents<App>();plaintext[SupplyParameterFromSession] private string? Items { get; set; } private void Add() { Items = string.IsNullOrEmpty(Items) ? Model.Item : $"{Items},{Model.Item}"; }
Two POSTs (apple, pear) and then two GETs showed apple,pear both times, and a
new client saw empty. What the attribute wrote into the session is not a plain
string:
textitems={"type":"System.String","value":"apple"}
The key came out as items, in lower case, and the value is a small JSON envelope.
So Session.SetString("Items", "manual") in some other endpoint created a second,
unrelated key and the page still showed apple. If other code must share the
value, go through the same attribute or the same format, or read it with
ISession using the exact key and envelope you saw. The session itself is the
usual ASP.NET Core one: a cookie with an id, and the data in IDistributedCache.
AddDistributedMemoryCache() is per process, so more than one instance needs
a shared cache such as Redis (see caching-imemorycache-hybridcache-and-distributed).
CacheView: cache a block of the page
CacheView renders its child content once and serves the stored HTML afterwards.
According to the reference documentation, on a hit the child components are not
instantiated or rendered at all. It varies by what you list:
plaintext<CacheView VaryByQuery="category" ExpiresAfter="TimeSpan.FromMinutes(5)"> <p id="stamp">@DateTime.UtcNow.Ticks</p> <p id="category">@(Category ?? "all")</p> </CacheView>
Requests with DateTime.UtcNow.Ticks as a probe:
text/news 639300741948323260 all /news 639300741948323260 all (same ticks: served from cache) /news?category=a 639300741948368530 a /news?category=a 639300741948368530 a (cached) /news?category=b 639300741948375460 b (new entry)
The other VaryBy* properties in the 11.0.0 reference are VaryByRoute,
VaryByHeader, VaryByCookie, VaryByUser, VaryByCulture and a free VaryBy
string, plus Enabled, CacheKey, ExpiresAfter, ExpiresOn and ExpiresSliding.
The store is in memory by default, with a size limit (CacheViewSizeLimit, 100 MB
by default according to the docs); if a HybridCache is registered it is used
instead, and then sliding expiration is not supported. I did not exercise these
last points. It is a different tool from output caching (output-caching-and-http-caching):
that caches a whole response by policy, this one caches a part of a page inside the
component tree.
Smaller additions, and the CSRF side of all this
<BasePath /> renders <base href="/" /> from the app's base path, replacing the
hand-written tag (run: the output was exactly <base href="/" />). The release
notes also list EnvironmentView and DisplayName; I did not run those two.
Every pattern here starts with a POST, so the CSRF rule matters. With no
UseAntiforgery() in the pipeline, a POST to a page behaved like this on the GA:
textno Sec-Fetch-Site, no Origin 302 Sec-Fetch-Site: same-origin 302 Sec-Fetch-Site: cross-site 400 Origin: http://evil.example 400 Sec-Fetch-Site: same-origin + Origin: http://localhost 302
That is the automatic check described in csrf-protection-antiforgery-tokens-and-fetch-metadata
and whats-new-in-aspnet-core-11, and it protects the flash and cart forms without
a token. A client that sends no browser headers at all (as my test client) passes.
Trade-offs
- A cookie is bounded and travels on every request. TempData in a cookie is
stateless on the server but limited by the cookie size, so keep messages short and
keep objects out.csharp
Message = "saved"; // an id or a short text, not a whole model - The attribute does not make TempData one-shot by itself. Reading the property
leaves the value in place; assign
nullafter the read or useITempData.Get.csharpShown = Message; Message = null; - Session is per visitor but costs memory and a shared store. It works out of the
box on one instance with
AddDistributedMemoryCache, and silently loses data on a second instance unless the cache is shared. - The session key and format belong to the attribute. Code that writes the same
key by hand with
SetStringwill not be read back, and the other way around. CacheViewis shared across users unless you vary it. A block that shows anything personal must vary by user (VaryByUser) or stay out of the cache, and stale data is the price of the expiration you chose.plaintext<CacheView VaryByUser="true" ExpiresAfter="TimeSpan.FromMinutes(1)">...</CacheView>- This is static SSR state. Interactive components have the circuit for live state; these tools exist because static pages do not.