bigskysoftware / bigskysoftware/htmx

hx-sse: cleanup never aborts the initial connection's fetch (leaked streams, lost events in Firefox)

Open Beginner friendly
#3,974 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.