Does htmx 4's 60s defaultTimeout kill the SSE stream?¶
No. The extension disarms the timeout the moment it takes the response over, and the timer is observably cleared 15 ms after it is set. A stream held open for 7m41s — nearly eight timeout windows — never dropped, never reconnected, and still delivered.
Issue #13. Date: 2026-09-22.
main, htmx 4.0.0. Observed against a second dev server on :8092 (the documented dev port
:8090 was already in use), sharing the dev DB, with its access log captured to a file.
Sources (all primary)¶
- [S1] htmx 4.0.0 source as installed,
node_modules/htmx.org/dist/htmx.js— version confirmed withnode -p "require('./node_modules/htmx.org/package.json').version". - [S2] hx-sse extension source as installed,
node_modules/htmx.org/dist/ext/hx-sse.js. - [S3] Runtime observation in Chrome against the running app, this session. Line numbers below are from the installed files, not from unpkg.
Why this needed running and not more reading¶
The attribute audit (#7) could not settle it from source, and #6 had already shown that
source-only reasoning about this extension can be confidently wrong — the hx-trigger claim
looked right and was a live bug. The failure mode here is also silent: the page keeps
rendering, the lists just stop moving.
The mechanism¶
Core arms the timeout for every request, including the one that opens the stream:
// dist/htmx.js:669-676 [S1]
#initTimeout(ctx) {
let timeout = ctx.request.timeout != null
? this.parseInterval(ctx.request.timeout)
: this.config.defaultTimeout;
if (timeout) {
ctx.requestTimeout = setTimeout(() => ctx.request?.abort?.(), timeout);
}
}
and only clears it in the finally of the request lifecycle (dist/htmx.js:619) — which for
a stream is after await ctx.extensionPromise, i.e. minutes or hours later. So if nothing
else happened, the 60s abort would land mid-stream.
Something else happens. The extension clears it by hand on takeover:
// dist/ext/hx-sse.js — htmx_before_response
if (!contentType?.includes('text/event-stream')) return;
// Take over streaming; use extensionPromise to hold the request open
clearTimeout(ctx.requestTimeout);
Reconnections never re-enter that path at all: they re-fetch with a raw fetch() and their
own AbortController (hx-sse.js:220-229), so no core timeout is ever armed for them. Only
the first connection of a stream was ever a candidate, which is what made the test
delicate — a connection that has already reconnected once is not a valid subject.
Observation 1 — the short fuse (decisive)¶
Waiting out one 60s window proves little. Instead, the same mechanism was run with an
unmistakable fuse value and setTimeout/clearTimeout wrapped to record it: open a fresh
hx-sse:connect element with htmx.config.defaultTimeout = 4321, then watch that specific
timer.
| Observed | Value |
|---|---|
Timers created with delay 4321 |
1 (so core does arm it for SSE) |
| Armed at | t = 0.001 s |
| Cleared at | t = 0.016 s — same tick as htmx:sse:after:connection |
| Fired | never |
| State at t ≈ 8 s (~2× the fuse) | connected, aborted: false, attempt: 0 |
A 4.3-second timeout did not end the stream. The timer is armed and then disarmed, exactly as the source says.
Observation 2 — idle at the real 60s value¶
A connecting element opened at 12:28:23Z with the stock defaultTimeout: 60000 and left
alone for 409 s (6m49s), then broadcast to at t=461 s.
Precondition, and it matters: pauseOnBackground was neutralised on this connection
(visibilityHandler removed, config.pauseOnBackground = false) about 800 ms after it
opened. That was not optional — the tab reported hidden for most of the window, so with the
stock pause in place the run would have measured the pause, exactly the trap described below.
So this is a stock-defaultTimeout run with one unrelated mechanism disabled, not stock
end-to-end behaviour. It isolates the timeout, which is the question; it says nothing about
what a backgrounded tab does, which is #11's question.
- Connection events after t=0.02 s: none. No
htmx:sse:error, nohtmx:sse:close, no secondhtmx:sse:before:connection. attempt: 0,aborted: falsethroughout.- 21
keep-alivemessages at a steady 20 s spacing (django-eventstream's own heartbeat), last at t=400 s. - Server side: one
GET /items/stream/access-log line for the whole window, andlsofshowed the same ephemeral port (55825) established start to finish. The TCP connection was never remade. Atail -fon the access log, armed for the second half of the window, delivered zero events.
On instruments. The ticket asked for "the network panel and django-eventstream's
server-side logging". Server-side logging was watched as asked. The network panel was replaced
by three stronger substitutes, because a devtools panel cannot be quoted in a finding: the
extension's own htmx:sse:* events captured with timestamps, lsof port identity (a reconnect
must produce a new ephemeral port), and the access log. Anything the network panel would have
shown as a dropped-and-reopened request appears in all three.
Liveness, not just an open socket. At t=461 s a real Item was created through the app's
own endpoint (POST /wishlist/add/ → 204), and the aged connection delivered update
immediately; the page's list re-fetched (GET /events/list/…). A 7m41s-old connection still
works end to end. (The throwaway Item was deleted afterwards.)
Decision¶
No fix. Change nothing. No hx-config timeout override on the connecting elements, no
htmx.config.defaultTimeout change, and no reliance on reconnection to paper over a drop.
The extension already handles this, deliberately, and an override would be cargo cult.
Two traps this run walked into¶
Both cost a full observation window, and both would mislead the next person.
pauseOnBackground looks exactly like the timeout firing. The first run showed a
reconnect at t=62.6 s — two seconds past the 60 s mark, which is as convincing a false
positive as this question could produce. It was not the timeout. The probe caught
visibilitychange → hidden at t=58.7 s and back to visible at t=62.6 s: the extension
defaults pauseOnBackground: true for hx-sse:connect, cancels the reader while the tab is
hidden, and reopens on unpause. The tell is that no htmx:sse:error fires on that path,
and the reconnect arrives with attempt: 1 — a timeout abort would surface differently. Any
future timing observation must neutralise the pause first (remove the connection's
visibilityHandler and set config.pauseOnBackground = false) or it is measuring the wrong
thing.
Cross-process send_event does not reach a connected client. With
EVENTSTREAM_STORAGE_CLASS = DjangoModelStorage and no channel layer, calling send_event
from a separate manage.py-style process wrote the event but delivered nothing to a browser
attached to the running server — the probe saw no update. This is a property of the test
harness, not of the stream, and reading it as a dead stream would be wrong. Broadcast through
the app's own HTTP endpoint on the server under observation instead.
Incidental findings¶
- django-eventstream's heartbeats cannot corrupt a list. Its
stream-openandkeep-alivearrive as named events with empty data. htmx 4 dispatches named events as DOM events and only swaps unnamed ones (hx-sse.js:if (!msg.hasData && !msg.event) continue), so the 20 s heartbeat never reacheshtmx.swapand never touches#events-list. Worth recording because a 20 s "message" that did swap would blank the list on a timer. - A lead for #11, not a finding. The unpause path re-fetches with a
Last-Event-IDheader carried inconnection.lastEventId, andDjangoModelStorageis a replaying store. That is the shape of an answer to "does a waking phone re-fetch or come back stale" — but it was not tested here, and the ids seen on the idle connection were empty (heartbeats carry none). Test it on a real device before believing it.
Reproducing¶
Nothing here needs a device — a desktop browser against the dev server is enough:
- Open
/events/, then in the console neutralise the pause on the live connection:const c = document.querySelector('[hx-sse\\:connect]')._htmx.sse;document.removeEventListener('visibilitychange', c.visibilityHandler); c.config.pauseOnBackground = false; - For the fuse test, wrap
setTimeout/clearTimeout, sethtmx.config.defaultTimeoutto a distinctive value, append a freshhx-sse:connectelement,htmx.processit, and check whether a timer with that delay is created and cleared. - Server side, watch
GET /items/stream/access-log lines andlsof -nP -iTCP:<port> -sTCP:ESTABLISHED— a reconnect shows as both a new log line and a new ephemeral port.