Shlok Parida

The htmx request that is not a swap

HX-Request: true reads like "send a fragment". It does not mean that. It means the request came from htmx, and htmx makes one kind of request that needs a complete document back.

The server code that gets this wrong is the obvious code:

name := "layout"
if r.Header.Get("HX-Request") == "true" {
    name = "fragment"          // wrong
}

Every click works. The back button works too, most of the time, which is what makes this expensive to find.

What the back button actually does

htmx snapshots the DOM before each navigation and keeps the last htmx.config.historyCacheSize of them — ten by default. Go back within those ten and the restore is served from the snapshot: no request, no server, nothing to get wrong.

Go back past ten, or return to the tab after the cache was evicted, and it is a miss. On a miss, from the documentation:

"htmx will make an ajax request to the given URL, with the header HX-History-Restore-Request set to true, and expects back the HTML needed for the entire page."

The entire page. That request also carries HX-Request: true, so the check above answers it with a fragment, and htmx restores that fragment into the history element — the body, unless hx-history-elt says otherwise.

What the user sees is a page with no header and no navigation, correctly styled, because the <head> was never touched. Correct styling on missing chrome is the detail that sends people looking for a template bug instead of a caching one.

Two questions, not one

func isHXSwap(r *http.Request) bool {
	return r.Header.Get("HX-Request") == "true" &&
		r.Header.Get("HX-History-Restore-Request") != "true"
}

"Did this come from htmx" and "is this a live swap" are different questions. Only the second one should select a fragment.

The client-side answer, and why the header is still better

htmx 2.0.5 added htmx.config.historyRestoreAsHxRequest. It defaults to true, which is the behaviour above; set it to false and htmx stops sending HX-Request on history-restore requests entirely, at which point a bare HX-Request check is correct.

That works. The header check is still the one to write, for two reasons. It holds on every version — historyRestoreAsHxRequest does not exist in 2.0.4, which is what a lot of sites are still serving — and it does not make the server's correctness depend on a client-side setting in a page the server may not have rendered.

Check what you are actually shipping before you pick. The version is in the file:

grep -o 'version:"[0-9.]*"' static/htmx.min.js

Vary is not optional here

One URL, two response bodies, chosen by a request header. That is content negotiation, and a cache that does not know about it will hand the wrong one out. MDN: "Including a Vary header ensures that responses are separately cached based on the headers listed in the Vary field."

w.Header().Set("Vary", "HX-Request")

Without it you do not need a CDN to reproduce the bug. Navigate to a page through htmx, then open the same URL in a new tab: the browser's own cache can return the fragment, with no document around it.

The rule this all rests on

"If you push a URL into the history, you must be able to navigate to that URL and get a full page back! A user could copy and paste the URL into an email, or new tab."

Build every route to render standalone and the history-restore case costs almost nothing — the full-document renderer already exists and the only requirement is that you do not skip it. Build fragments first and bolt the documents on later, and you will meet this bug from three directions: the back button, a shared link, and a crawler.