Skip to content

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 with node -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, no htmx:sse:close, no second htmx:sse:before:connection.
  • attempt: 0, aborted: false throughout.
  • 21 keep-alive messages 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, and lsof showed the same ephemeral port (55825) established start to finish. The TCP connection was never remade. A tail -f on 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-open and keep-alive arrive 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 reaches htmx.swap and 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-ID header carried in connection.lastEventId, and DjangoModelStorage is 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:

  1. 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;
  2. For the fuse test, wrap setTimeout/clearTimeout, set htmx.config.defaultTimeout to a distinctive value, append a fresh hx-sse:connect element, htmx.process it, and check whether a timer with that delay is created and cleared.
  3. Server side, watch GET /items/stream/ access-log lines and lsof -nP -iTCP:<port> -sTCP:ESTABLISHED — a reconnect shows as both a new log line and a new ephemeral port.