bigskysoftware / bigskysoftware/htmx
hx-sse: cleanup never aborts the initial connection's fetch (leaked streams, lost events in Firefox)
- Dominant language
- JavaScript
- Stars
- 49.4k
- Forks
- 1.7k
- Avg merge
- 3d 22h
- Merged PRs (30d)
- 30
Description
Been building a chat widget on htmx 4 (four-dev) with hx-sse and hit a fun one: when you open a conversation with someone new, the whole widget gets swapped (old panel out, new panel in, both carrying `hx-sse:connect` to the same URL) — and in Firefox, the first message you send into that fresh conversation just... never shows up. It's in the database, it comes back on the stream, but the log stays empty until you hit F5. Chrome is fine. Took me an evening of instrumenting both ends to figure out why, and the root cause is in the extension's teardown.
`cleanup()` does this:
```js
connection.abortController?.abort();
connection.reader?.cancel?.();
```
which looks right, except the *initial* connection is created with `abortController: null` (hx-sse.js, ~L125 on four-dev) — only reconnect fetches ever get a real controller (~L211). The initial response came from core's fetch, and core's controller is sitting right there on the ctx (`ctx.request.abort` / `ctx.request.signal`), but it's never wired up. So tearing down a connection that never reconnected cancels the body reader and nothing else.
Two things fall out of that:
1. The server never finds out the client left. `reader.cancel()` stops consumption, but at least in Firefox the HTTP transaction stays open — I watched my server-side stream handlers keep running (and "sending") for minutes after their element was gone from the DOM. One leaked stream per swap.
2. This is the part that actually broke my app: in Firefox that cancelled-but-not-aborted transaction still counts as a busy connection, and Firefox schedules the *replacement* element's GET to the same URL behind it. In my traces the new stream's request sat unsent for 3+ seconds and only got dispatched when some unrelated request to the same host completed and freed a slot. Anything published in that window is gone for good — SSE has no backlog and a first connection has no `Last-Event-ID` to replay from. Hence the vanishing first message: the POST wins the race against a connect that hasn't even left the browser yet. Chrome doesn't hit any of this because its `reader.cancel()` actually kills the transaction.
Fix is one line — hand the initial connection core's controller instead of null:
```js
// in handleSSEResponse(ctx)
abortController: { abort: ctx.request.abort, signal: ctx.request.signal },
```
Keeping `signal` on the shim matters, because the streaming loop's catch checks `connection.abortController?.signal?.aborted` to tell teardown apart from a real network error — abort-only and you get a spurious `htmx:sse:error` on every cleanup. Reconnects overwrite the field with a real controller like before, so nothing else changes.
I've been running this patch on my vendored copy and it fixes both the leak and the Firefox starvation. Happy to send a PR if you want it.
Contributor guide
Research direction
Start in hx-sse.js at handleSSEResponse(ctx), around L125, and compare the initial connection setup with the reconnect fetch around L211. Reproduce teardown and replacement connections in Firefox, then verify that cleanup aborts the initial request without producing a spurious htmx:sse:error and that the replacement stream connects promptly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100