# Blazor CacheView in .NET 11: Cache Part of a Server-Rendered Page

> Cache a public category list while the rest of a Blazor page renders on every request. Five HTTP requests show how CacheView keys, cache hits, and expiration work.

Published: 2026-11-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/blazor-cacheview-dotnet-11

Blazor's new `CacheView` in [**.NET 11**](https://milanjovanovic.tech/blog/whats-new-dotnet-11) lets a static server-rendered page reuse a child component's HTML across GET requests.
You can cache a book list for everyone browsing the same category while Blazor renders each shopper's cart on every request.
On a cache hit, the list component never runs.

## Which Part of the Page Should You Cache?

Caching the whole catalog page by category would also cache the first shopper's cart badge.
You could add a shopper ID to the key, but then each shopper would have a separate copy of the same list.
Put `CacheView` around the list and leave the cart badge outside it.
If several pages need the same underlying product data but render different HTML, [**caching the data in ASP.NET Core**](https://milanjovanovic.tech/blog/caching-in-aspnetcore-improving-application-performance) lets each page still generate its own markup.

## Set Up a Static SSR Page

Create an app with the .NET 11 SDK.
The static SSR template registers `AddRazorComponents()` and maps `MapRazorComponents<App>()`, which is enough to try the default in-memory component cache.

```bash
dotnet new blazor -n CacheDemo --interactivity None --no-https
cd CacheDemo
```

Add `Components/Pages/Catalog.razor`.
The page has its own render number so you can tell when two HTTP responses came from different page instances.
The five-second lifetime makes expiration easy to observe.

```razor
@page "/catalog"

<p>Page render: @pageRender</p>

<CacheView VaryByQuery="category" ExpiresAfter="TimeSpan.FromSeconds(5)">
    <CategoryList Category="@(Category ?? "all")" />
</CacheView>

@code {
    [SupplyParameterFromQuery]
    public string? Category { get; set; }

    private static int pageRenders;
    private readonly int pageRender = Interlocked.Increment(ref pageRenders);
}
```

Beside it, create `Components/Pages/CategoryList.razor`.
The child increments its counter in `OnInitialized`.
If two responses show the same list number and different page numbers, Blazor reused the list.

```razor
<p>List render: @listRender (@Category)</p>

@code {
    [Parameter]
    public string Category { get; set; } = "";

    private static int listRenders;
    private int listRender;

    protected override void OnInitialized()
        => listRender = Interlocked.Increment(ref listRenders);
}
```

Put expensive work **inside the cached child** when you want a hit to skip it.
If the parent loads the books before passing them as a parameter, it still performs that lookup every time the parent renders.
Start the app with `dotnet run --no-launch-profile --urls http://127.0.0.1:15298`.

## What Happens on a Cache Hit?

Open a second terminal.
Make the first four requests in quick succession, then wait long enough for the first entry to expire.
The commands below show the two paragraphs from each response.

```bash
for category in books books tools books; do
  curl -fsS "http://127.0.0.1:15298/catalog?category=$category" |
    grep -oE '<p>(Page|List) render:[^<]*</p>'
done
sleep 6
curl -fsS 'http://127.0.0.1:15298/catalog?category=books' |
  grep -oE '<p>(Page|List) render:[^<]*</p>'
```

Starting with a fresh process, ASP.NET Core 11.0.0 returned these values.

| Request | Page render | List render |
| --- | --- | --- |
| 1. books | 1 | 1 (books) |
| 2. books | 2 | 1 (books) |
| 3. tools | 3 | 2 (tools) |
| 4. books | 4 | 1 (books) |
| 5. books, after expiration | 5 | 3 (books) |

On the second request, `Catalog` rendered again while `CategoryList.OnInitialized` never ran.
The third request had a new category and needed its own entry.
The fourth request reused the first books entry.
After six seconds, the same URL created list instance 3.

## Which Inputs Belong in the Cache Key?

The `Category` parameter tells the **child** what to render.
`VaryByQuery="category"` tells the **cache** which requests can share that rendering.
Omit `VaryByQuery` and the shopper who asks for tools can get a list of books.

For a localized catalog, also set `VaryByCulture="true"` if the child's output depends on the request culture.
If the category comes from a route such as `/catalog/{category}`, use `VaryByRoute="category"`.
In a loop rendering multiple lists, give each `CacheView` a distinct `CacheKey`, such as the department ID, since multiple iterations share the same position in the component tree.

Keep the cart and permission-dependent actions outside the shared list.
The default cache can serve multiple users, and authorization logic inside a cached child doesn't run again on a hit.
A deleted book can stay in the list until `ExpiresAfter` elapses.
If stock status needs to update sooner, give it a shorter [**expiration**](https://learn.microsoft.com/en-us/aspnet/core/blazor/state-management/cacheview-component?view=aspnetcore-11.0) or render it outside the cached list.

## Summary

When you replace the counters with a database lookup, put that lookup in `CategoryList.OnInitializedAsync`.
Request books twice and check the query log.
You should see one lookup for books, a second when you switch to tools, and a third if you come back to books after the entry expires.

## Frequently asked questions

### What does CacheView cache in Blazor?

It stores the rendered markup of a component subtree during a static server-rendered GET. On a hit, Blazor reuses that markup and skips initializing the cached children. Other components on the page can still render for the request.

### Does CacheView refresh when a component parameter changes?

Passing a new value to a child does not automatically create a different entry. Include the input in the cache key, for example with VaryByQuery for category or VaryByCulture for localized markup.

### How long does Blazor CacheView keep an entry?

Set ExpiresAfter for an absolute lifetime measured from creation. With the default in-memory store, omitting expiration gives the entry a 30-second lifetime. Setting only ExpiresSliding still leaves that 30-second absolute limit in place.
