Caching
If the same URL returns a full page to normal requests and a fragment to htmx requests, caches (browser, CDN, reverse proxy) must be told that the response varies by request header. Otherwise a cached fragment can be served as a whole page, for example after back navigation or when someone opens the URL directly. That is the whole job of WithVary().
The bug
Section titled “The bug”public IActionResult OnGet() => // same URL, two representations Request.IsHtmx() ? Partial("_List") : Page();- A user clicks an
hx-getlink; the browser caches the fragment for/products. - They open
/productsdirectly (or hit back). The browser reuses the cached fragment: an unstyled page with no layout.
The fix: WithVary()
Section titled “The fix: WithVary()”public IActionResult OnGet(){ // Vary: HX-Request Response.Htmx(h => h.WithVary()); return Request.IsHtmx() ? Partial("_List") : Page();}WithVary() appends HX-Request to the Vary response header. Caches now store htmx and non-htmx responses separately.
Also works in an MVC action:
[HttpGet("products")]public IActionResult Index(){ Response.Htmx(h => h.WithVary()); return Request.IsHtmx() ? PartialView("_List", model) : View(model);}Varying on other headers
Section titled “Varying on other headers”WithVary(params string[] headerNames) appends exactly the headers you name. Names already present in Vary are not duplicated, and other Vary values you set earlier are preserved.
Response.Htmx(h => h.PreventPush().WithVary("HX-Source", "HX-Target"));Rule of thumb: vary on every request header your response depends on. If you pick the representation from HX-Target, include HX-Target.
HX-Request-Type
Section titled “HX-Request-Type”If you branch on IsHtmxPartial() / IsHtmxFullRequest() (the HX-Request-Type header), include it in Vary:
Response.Htmx(h => h.WithVary("HX-Request", "HX-Request-Type"));return Request.IsHtmxPartial() ? Partial("_List") : Page();Related behavior
Section titled “Related behavior”- htmx 4 re-fetches from the server on history navigation (there is no built-in history cache), so correct
Varymatters for back/forward too.Request.IsHtmxHistoryRestoreRequest()tells you such a request. - Response-level cache attributes still apply:
[ResponseCache(Duration = 60, VaryByHeader = "HX-Request")]is an alternative to callingWithVary()when you use ASP.NET response caching. - A separate URL per representation (for example
/productsand/products/list) avoids the problem entirely.
See it live: the /ResponseHeaders demo sets Vary through WithVary() and WithVary("HX-Source", "HX-Target") (run dotnet run --project samples/Htmx4.Sample; source).
Related: Request detection, Response headers.
From the blog: Dynamic Htmx Islands with ASP.NET Core pairs response/output caching with dynamic islands.