Summary
Live views wired with hx-sse:connect + broadcast() silently go stale whenever the tab is backgrounded, with no built-in recovery. The combination of two framework behaviors causes events to be lost permanently:
- htmx 4's SSE extension sets
pauseOnBackground: true for hx-sse:connect. When the tab is hidden it calls connection.reader.cancel(), which closes the stream and unsubscribes server-side.
sse_endpoint(...) has no replay by default. Without buffer_size, _dispatch_local assigns no event id, broadcasts carry no id: field, and on reconnect there is nothing to replay. htmx reconnects but never re-fetches, so any broadcast() sent during the hidden window is lost forever.
Net effect: trigger an action, glance at another tab while the worker finishes, come back — the view is stuck on the old state until a manual reload. It is most visible with fast transitions (a sub-second status flip almost always lands during a navigation gap or glance-away), and invisible in local dev where the CSP is report-only and tabs stay focused during testing.
Reproduction
- A page with
<div hx-sse:connect="…/events"><div hx-get="…" hx-trigger="my-event from:#stream">…</div></div>.
- Open it; let the SSE connect.
- Background the tab (or simulate:
Object.defineProperty(document,'hidden',{get:()=>true}); document.dispatchEvent(new Event('visibilitychange'))).
- From another process,
broadcast(channel, event="my-event", data="1").
- Foreground the tab. htmx reconnects, but the consumer never re-fetches — the view stays stale. A plain
EventSource opened alongside (no pause logic) does receive the event, confirming the broadcast was delivered and only the paused htmx connection missed it.
Why it is a footgun
The documented pattern (hx-sse:connect + a hx-get consumer triggered by a named event) looks correct and works in every focused-tab test, but quietly loses events the moment attention shifts. Nothing in the SSE docs flags that backgrounding drops events or that recovery requires opting into a replay buffer / reconnect re-fetch.
Suggestions (any one would help)
- Docs: note that
pauseOnBackground drops events and recommend a resync-on-reconnect pattern, e.g. add htmx:after:sse:connection from:#stream to the consumer's hx-trigger so it re-fetches current state on every (re)connection.
- Consider making
buffer_size (Last-Event-ID replay) easier to reason about across multiple frontend workers — per-process monotonic ids mean a reconnect to a different worker can't replay reliably, so the buffer isn't a robust multi-worker answer today.
- Optionally expose/recommend a knob to disable
pauseOnBackground for views that must stay live.
Workaround we shipped
Added htmx:after:sse:connection from:#stream alongside the named event in the consumer's hx-trigger, so the view re-fetches current state on reconnect and recovers missed events (one idempotent fetch on initial connect). Confirmed htmx:before:sse:connection / htmx:after:sse:connection fire on the connecting element on every reconnect.
Environment: vibetuner 10.22.3, htmx.org 4.0.0-beta4.
Filed by Claude Code.
Summary
Live views wired with
hx-sse:connect+broadcast()silently go stale whenever the tab is backgrounded, with no built-in recovery. The combination of two framework behaviors causes events to be lost permanently:pauseOnBackground: trueforhx-sse:connect. When the tab is hidden it callsconnection.reader.cancel(), which closes the stream and unsubscribes server-side.sse_endpoint(...)has no replay by default. Withoutbuffer_size,_dispatch_localassigns no event id, broadcasts carry noid:field, and on reconnect there is nothing to replay. htmx reconnects but never re-fetches, so anybroadcast()sent during the hidden window is lost forever.Net effect: trigger an action, glance at another tab while the worker finishes, come back — the view is stuck on the old state until a manual reload. It is most visible with fast transitions (a sub-second status flip almost always lands during a navigation gap or glance-away), and invisible in local dev where the CSP is report-only and tabs stay focused during testing.
Reproduction
<div hx-sse:connect="…/events"><div hx-get="…" hx-trigger="my-event from:#stream">…</div></div>.Object.defineProperty(document,'hidden',{get:()=>true}); document.dispatchEvent(new Event('visibilitychange'))).broadcast(channel, event="my-event", data="1").EventSourceopened alongside (no pause logic) does receive the event, confirming the broadcast was delivered and only the paused htmx connection missed it.Why it is a footgun
The documented pattern (
hx-sse:connect+ ahx-getconsumer triggered by a named event) looks correct and works in every focused-tab test, but quietly loses events the moment attention shifts. Nothing in the SSE docs flags that backgrounding drops events or that recovery requires opting into a replay buffer / reconnect re-fetch.Suggestions (any one would help)
pauseOnBackgrounddrops events and recommend a resync-on-reconnect pattern, e.g. addhtmx:after:sse:connection from:#streamto the consumer'shx-triggerso it re-fetches current state on every (re)connection.buffer_size(Last-Event-ID replay) easier to reason about across multiple frontend workers — per-process monotonic ids mean a reconnect to a different worker can't replay reliably, so the buffer isn't a robust multi-worker answer today.pauseOnBackgroundfor views that must stay live.Workaround we shipped
Added
htmx:after:sse:connection from:#streamalongside the named event in the consumer'shx-trigger, so the view re-fetches current state on reconnect and recovers missed events (one idempotent fetch on initial connect). Confirmedhtmx:before:sse:connection/htmx:after:sse:connectionfire on the connecting element on every reconnect.Environment: vibetuner 10.22.3, htmx.org 4.0.0-beta4.
Filed by Claude Code.