From ee9402063897061b5ba486300d3ef5fe4a891ea5 Mon Sep 17 00:00:00 2001
From: James Barlow <332269+manwithacat@users.noreply.github.com>
Date: Mon, 7 Sep 2026 23:18:26 +0100
Subject: [PATCH] rfc: polling is the element's lifetime
Pin the htmx 4 rule: an every trigger is armed on a node and cleared
when that node leaves the DOM. HTTP 286 is a no-op. innerHTML cannot
self-stop; outerHTML / outerMorph / HX-Reswap: outerHTML of a
triggerless copy can.
No cancelPolling API. Related: #4040.
---
src/scripts/upgrade-check.py | 10 ++++
src/skills/htmx-upgrade-from-htmx2.md | 5 ++
test/manual/upgrade/polling-286.html | 7 +++
test/tests/attributes/hx-trigger-every.js | 49 +++++++++++++++++++
www/src/content/docs.md | 16 ++++++
www/src/content/docs/whats-new-in-htmx-4.md | 9 ++++
www/src/content/patterns/04-polling.md | 14 +++++-
.../reference/01-attributes/07-hx-trigger.md | 14 +++++-
8 files changed, 121 insertions(+), 3 deletions(-)
create mode 100644 test/manual/upgrade/polling-286.html
create mode 100644 test/tests/attributes/hx-trigger-every.js
diff --git a/src/scripts/upgrade-check.py b/src/scripts/upgrade-check.py
index 67570ae78..fa84a07f6 100755
--- a/src/scripts/upgrade-check.py
+++ b/src/scripts/upgrade-check.py
@@ -491,6 +491,15 @@ def check_text(raw_text, filepath, issues):
issues.append(Issue(filepath, lineno, "removed-header",
f'"{header}" is removed → {fix}'))
+ # HTTP 286 (Intercooler / htmx 2 poll cancel) is a no-op in v4
+ if re.search(r"\b286\b", line) and re.search(
+ r"poll|hx-trigger|CancelPolling", line, re.I):
+ issues.append(Issue(
+ filepath, lineno, "polling-286",
+ 'HTTP 286 no longer stops polling → swap the poller for markup '
+ 'without hx-trigger="every …" (outerHTML / outerMorph / '
+ 'HX-Reswap: outerHTML)'))
+
# ---------------------------------------------------------------------------
# File processing
@@ -570,6 +579,7 @@ def collect_files(paths, extensions):
"renamed-config": "\033[33m", # yellow
"removed-config": "\033[31m", # red
"removed-header": "\033[31m", # red
+ "polling-286": "\033[31m", # red
}
RESET = "\033[0m"
diff --git a/src/skills/htmx-upgrade-from-htmx2.md b/src/skills/htmx-upgrade-from-htmx2.md
index c59a99c1d..8990def56 100644
--- a/src/skills/htmx-upgrade-from-htmx2.md
+++ b/src/skills/htmx-upgrade-from-htmx2.md
@@ -250,6 +250,11 @@ New request header: `HX-Request-Type` (`"full"` or `"partial"`).
Still supported: `HX-Trigger`, `HX-Push-Url`, `HX-Replace-Url`, `HX-Redirect`, `HX-Location`,
`HX-Refresh`, `HX-Retarget`, `HX-Reswap`, `HX-Reselect`.
+**Polling:** HTTP **286** no longer stops `hx-trigger="every …"`. The polling
+element is the poll. Stop by swapping **that element** for markup without
+`every` (`outerHTML` / `outerMorph`, or `HX-Reswap: outerHTML` if the request
+would otherwise `innerHTML`). Default `innerHTML` cannot self-stop.
+
## Step 9: Update JavaScript API Calls
| htmx 2 | htmx 4 |
diff --git a/test/manual/upgrade/polling-286.html b/test/manual/upgrade/polling-286.html
new file mode 100644
index 000000000..e1d9cf0e8
--- /dev/null
+++ b/test/manual/upgrade/polling-286.html
@@ -0,0 +1,7 @@
+
+
+
+
Waiting
+
+
+
diff --git a/test/tests/attributes/hx-trigger-every.js b/test/tests/attributes/hx-trigger-every.js
new file mode 100644
index 000000000..b03f938e1
--- /dev/null
+++ b/test/tests/attributes/hx-trigger-every.js
@@ -0,0 +1,49 @@
+describe('hx-trigger every polling lifetime', function() {
+
+ beforeEach(() => {
+ setupTest()
+ })
+
+ afterEach(() => {
+ cleanupTest()
+ })
+
+ it('HTTP 286 does not stop every polling', async function () {
+ mockResponse('GET', '/poll-286', 'tick', {status: 286})
+ createProcessedHTML('
0
')
+ await htmx.timeout(130)
+ fetchMock.calls.length.should.be.above(1)
+ })
+
+ it('innerHTML swap leaves the poller in the DOM so every continues', async function () {
+ mockResponse('GET', '/poll-inner', 'tick')
+ createProcessedHTML('
0
')
+ await htmx.timeout(130)
+ fetchMock.calls.length.should.be.above(1)
+ find('#pinner').getAttribute('hx-trigger').should.equal('every 40ms')
+ })
+
+ it('outerHTML response without every stops polling', async function () {
+ mockResponse('GET', '/poll-outer', '
done
')
+ createProcessedHTML('
go
')
+ await forRequest(200)
+ let afterFirst = fetchMock.calls.length
+ afterFirst.should.equal(1)
+ await htmx.timeout(120)
+ fetchMock.calls.length.should.equal(afterFirst)
+ find('#pouter').textContent.should.equal('done')
+ should.equal(find('#pouter').getAttribute('hx-trigger'), null)
+ })
+
+ it('HX-Reswap outerHTML of a triggerless copy stops an innerHTML poller', async function () {
+ mockResponse('GET', '/poll-reswap', '
')
+ await forRequest(200)
+ let afterFirst = fetchMock.calls.length
+ afterFirst.should.equal(1)
+ await htmx.timeout(120)
+ fetchMock.calls.length.should.equal(afterFirst)
+ find('#preswap').textContent.should.equal('complete')
+ should.equal(find('#preswap').getAttribute('hx-trigger'), null)
+ })
+})
diff --git a/www/src/content/docs.md b/www/src/content/docs.md
index 92af522a7..485d5bc54 100644
--- a/www/src/content/docs.md
+++ b/www/src/content/docs.md
@@ -362,6 +362,22 @@ This tells htmx:
> Every 2 seconds, issue a GET to /news and load the response into the div
+The polling element **is** the poll. htmx arms a `setInterval` on that node and
+clears it when the node leaves the DOM (or is cleaned up during a swap). There
+is no cancelled flag, no `htmx.cancelPolling()`, and **HTTP 286 does not stop
+polling** (that 2.x / Intercooler status is ignored).
+
+To stop from the server, return a representation of the same element **without**
+`hx-trigger="every …"`, using a swap that replaces the poller itself
+([`outerHTML`](/reference/attributes/hx-swap) / [`outerMorph`](/reference/attributes/hx-swap),
+or [`HX-Reswap`](/reference/headers/HX-Reswap)`: outerHTML` if the request would
+otherwise innerHTML). Pause and resume are the same: markup without the trigger,
+then markup with it.
+
+Default [`innerHTML`](/reference/attributes/hx-swap) cannot self-stop: the poller
+stays in the DOM, so the interval keeps firing. See [Polling](/patterns/polling)
+and [Progress Bar](/patterns/progress-bar).
+
#### Load Polling
Another technique that can be used to achieve polling in htmx is "load polling", where an element specifies
diff --git a/www/src/content/docs/whats-new-in-htmx-4.md b/www/src/content/docs/whats-new-in-htmx-4.md
index 1b6f6f666..6b822408b 100644
--- a/www/src/content/docs/whats-new-in-htmx-4.md
+++ b/www/src/content/docs/whats-new-in-htmx-4.md
@@ -53,6 +53,15 @@ gets swapped into the target. Design your error responses to work as swap conten
Revert: [`htmx.config.noSwap`](/reference/config/htmx-config-noSwap) `= [204, 304, '4xx', '5xx']`
+### HTTP 286 does not stop polling
+
+htmx 2 honored status `286` (Intercooler's cancel-polling signal) to stop
+`hx-trigger="every …"`. htmx 4 ignores it.
+
+The polling element **is** the poll. Stop by swapping that element for markup
+without `every` (`outerHTML` / `outerMorph`, or `HX-Reswap: outerHTML` when the
+request would otherwise `innerHTML`). See [Polling](/patterns/polling).
+
### [`hx-delete`](/reference/attributes/hx-delete) excludes form data
Like [`hx-get`](/reference/attributes/hx-get), [`hx-delete`](/reference/attributes/hx-delete) no longer includes the
diff --git a/www/src/content/patterns/04-polling.md b/www/src/content/patterns/04-polling.md
index ffb386c0c..e04c29a74 100644
--- a/www/src/content/patterns/04-polling.md
+++ b/www/src/content/patterns/04-polling.md
@@ -90,7 +90,12 @@ The interval unit is `ms`, `s`, or `m`. A bare number is milliseconds.
## Stopping the poll
-htmx clears the interval when the element leaves the DOM. To stop the poll, return the element without the trigger attributes.
+htmx clears the interval when the element leaves the DOM. The markup **is** the
+state: a node with `hx-trigger="every …"` polls; a node without it does not.
+
+To stop, return the **same element** without the trigger attributes, with a swap
+that replaces the poller (`outerMorph` / `outerHTML`). HTTP 286 is a no-op in
+htmx 4.
```html
@@ -99,7 +104,12 @@ htmx clears the interval when the element leaves the DOM. To stop the poll, retu
```
-The demo above uses this to pause. The Pause button posts to `/toggle`, and the server renders the card without `hx-get` and `hx-trigger`.
+If the poll uses the default `innerHTML` swap, the poller stays in the DOM and
+keeps firing. Override the swap for that response with
+[`HX-Reswap: outerHTML`](/reference/headers/HX-Reswap) (and return the wrapper
+without `every`), or poll with `hx-swap="outerMorph"` from the start.
+
+The demo above uses this to pause. The Pause button posts to `/toggle`, and the server renders the card without `hx-get` and `hx-trigger`. Resume is the inverse: return the card *with* the trigger.
## Conditional polling
diff --git a/www/src/content/reference/01-attributes/07-hx-trigger.md b/www/src/content/reference/01-attributes/07-hx-trigger.md
index 878986d3b..aaa942a37 100644
--- a/www/src/content/reference/01-attributes/07-hx-trigger.md
+++ b/www/src/content/reference/01-attributes/07-hx-trigger.md
@@ -93,18 +93,30 @@ Modifiers:
### `every