#4101·htmx

A response whose target has left the page still sets the title and merges the head

Author: mhalikosenCreated Sep 24, 2026Updated Sep 24, 2026

When the element a request swaps into is removed before its response arrives, htmx 4.0.0 still applies the response's page-level effects. The typical case is a poll on page A that answers after a boosted link has already shown page B. The swap lands in a detached element, but the old response sets document.title back to page A's title, fires htmx:after:swap, and hx-head appends page A's head nodes to page B, so page A's stylesheets apply to page B. With hx-head="merge" it also removes page B's own stylesheets and scripts.

Reproduction with htmx.org 4.0.0 (dist/htmx.js and dist/ext/hx-head.js next to the pages) in Chrome 154. The same server.mjs delays any URL carrying ?slow by 2 seconds.

server.mjs

javascript
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join } from "node:path";

const [root, port] = process.argv.slice(2);
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css" };
const delays = { "/slow.css": 3000 };

createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  const delay = url.searchParams.has("slow") ? 2000 : (delays[url.pathname] ?? 0);
  await new Promise((resolve) => setTimeout(resolve, delay));
  try {
    const body = await readFile(join(root, url.pathname === "/" ? "index.html" : url.pathname));
    res.writeHead(200, { "content-type": types[extname(url.pathname)] ?? "text/html", "cache-control": "no-store" });
    res.end(body);
  } catch {
    res.writeHead(404).end();
  }
}).listen(Number(port));

index.html

xml
<!doctype html>
<html>
<head>
  <script src="/htmx.js"></script>
  <script src="/hx-head.js"></script>
  <link rel="stylesheet" href="/home.css">
  <title>Home</title>
</head>
<body hx-boost:inherited="true">
  <a href="/other.html">Other</a>
  <div id="clock" hx-get="/?slow" hx-trigger="every 1s" hx-select="#clock" hx-swap="outerHTML">polling</div>
</body>
</html>

other.html

xml
<!doctype html>
<html>
<head>
  <script src="/htmx.js"></script>
  <script src="/hx-head.js"></script>
  <link rel="stylesheet" href="/other.css">
  <title>Other</title>
</head>
<body>
  <h1>Other page</h1>
</body>
</html>

home.css

css
body { background: #fee; }

other.css

css
h1 { color: green; }

Open / and click Other while a poll is on its way (every second one is).

Expected: the Other page keeps its title and only /other.css. Actual: 2 seconds later the title is "Home", /home.css sits after /other.css and the Other page turns pink. Events, in ms since the page loaded:

 587 htmx:before:request /?slow
 587 click Other
 616 htmx:after:swap /other.html      then title "Other", stylesheets /other.css
2599 htmx:after:request /?slow
2600 htmx:after:swap /?slow           title "Home", stylesheets /other.css /home.css

swap() writes the response's title to document.title whether or not its target is still in the page, and hx-head merges in htmx_before_response, before any swap.

Other libraries apply no part of such a response. Unpoly: "When a fragment is removed from the DOM (by rendering or explicit destroying), any request targeting this fragment (or its descendants) is aborted" (aborting requests). Turbo cancels a removed frame's request since hotwired/turbo#1399: "there's no reason to process any in-flight response, and doing so can cause unintended side effects." htmx 4 does the same for hx-sse, whose cleanup aborts its fetch since #3975.

The rule belongs to the target, not to the source: a response whose trigger left the page while its target is still there stays valid, as #3959 settled for out-of-band swaps.

What I would change: when a response arrives and its target is no longer connected, drop it before the body is read, so it neither swaps nor touches the title, the head or the history. Aborting the request when htmx removes its target would save the round trip too. The check belongs at arrival, not after the swap, because an outerHTML swap disconnects its own target.

A workaround that does the first part:

javascript
document.addEventListener("htmx:before:response", (event) => {
  if (!event.detail.ctx.target?.isConnected) event.preventDefault();
});

With it the reproduction keeps the title "Other" and only /other.css.

Edited: the first version keyed the check to the source element; #3959 shows the target is the right key.