The hx-sse extension lets one HTTP response stream many Server-Sent Events (SSE).
If you used sse in htmx 2.0, see migration notes.
Installing
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-sse.min.js"></script>
Usage
Update an Element
Start with a typical htmx request using hx-get:
<button hx-get="/ping"> Ping </button>
Instead of text/html, respond with text/event-stream:
HTTP/1.1 200 OK Content-Type: text/event-stream data: Pong
This unnamed event has a data: field but no event: field.
Backend libraries
- Python: FastAPI or
sse-starlette - Go:
go-sse - PHP: Laravel event streams
The event replaces the button’s content:
<button hx-get="/ping"> Pong </button>
htmx uses the same rules as with a text/html response:
Stream an Update
You can also stream HTML using multiple unnamed events:
HTTP/1.1 200 OK Content-Type: text/event-stream data: P data: Po data: Pon data: Pong
The button changes as each event arrives:
Ping → P → Po → Pon → Pong
Choose the Swap
Use hx-swap and hx-target to choose how and where updates swap:
<button hx-post="/generate" hx-target="next output" hx-swap="beforeend"> Generate </button> <!-- LLM tokens stream here --> <output></output>
Each unnamed event contains one text chunk:
HTTP/1.1 200 OK Content-Type: text/event-stream data: Hello data: , world data: !
hx-swap="beforeend" accumulates them in <output>:
<output>Hello, world!</output>
You can also use:
hx-selectto select content for the swaphx-select-oobto select more elements to swap
Update Elements
Use a normal htmx request to update several elements:
<button hx-get="/events">Connect</button> <div id="feed"></div> <div id="status">Offline</div>
The server sends two extra swaps using hx-swap-oob and <hx-partial>:
HTTP/1.1 200 OK Content-Type: text/event-stream data: <div id="status" hx-swap-oob="true">Online</div> data: <hx-partial hx-target="#feed"><p>New</p></hx-partial>
The page becomes:
<button hx-get="/events">Connect</button> <div id="feed"> <p>New</p> </div> <div id="status">Online</div>
Why wasn't the normal swap used?
After htmx extracts the extra swaps, the normal swap is empty:
(empty)
hx-swap-oob and <hx-partial> elements are extracted before the normal swap. By default, swapEmpty:false leaves the connection element unchanged.
The server can mix extra swaps with ordinary HTML:
HTTP/1.1 200 OK Content-Type: text/event-stream data: <p>New event</p> data: <hx-partial hx-target="#status">Busy</hx-partial>
The paragraph follows hx-target and hx-swap on the request element. The partial updates #status.
To disable the request element’s swap, set hx-swap="none":
<button hx-get="/events" hx-swap="none">Connect</button>
hx-swap-oob and <hx-partial> swaps still run.
Persistent Connections
Use a persistent connection to keep receiving server updates.
Open Connections
Add hx-sse:connect to the element that receives them:
<div hx-sse:connect="/events"></div>
Use hx-trigger to connect after an event:
<button id="connect">Connect</button> <div hx-sse:connect="/events" hx-trigger="click from:#connect"> </div>
All hx-trigger modifiers are supported.
Close Connections
Close a connection when a specific named event arrives:
<div hx-sse:connect="/progress" hx-sse:close="done"></div>
The server sends:
HTTP/1.1 200 OK Content-Type: text/event-stream event: done data: Complete
Client handlers for the done event run before the connection closes.
Configure Connections
You can configure hx-sse in three places:
-
<meta name="htmx-config">sets global defaults from HTML.<meta name="htmx-config" content="sse.reconnectDelay:1s sse.reconnectMaxAttempts:5"> -
htmx.config.ssesets global defaults from JavaScript.htmx.config.sse.reconnectDelay = '1s' htmx.config.sse.reconnectMaxAttempts = 5 -
hx-configoverrides the defaults for one connection.<div hx-sse:connect="/events" hx-config="sse.reconnectMaxAttempts:2"> </div>
These values are read when stream handling begins.
Replay Messages
Add id: to recover messages missed while disconnected:
HTTP/1.1 200 OK Content-Type: text/event-stream id: event-42 data: <p>New message</p>
The stream keeps the current event ID:
- An event without
idinherits the current ID. - An empty
id:clears the ID. - An ID-only block ending with a blank line updates or clears the ID without dispatching a message.
On reconnect, hx-sse includes Last-Event-ID when the current ID is not empty:
Last-Event-ID: event-42
This tells the server where the client left off, so it can replay missed messages.
Server Client event-42 -------------------> received event-43 --------X disconnected <------------------- Last-Event-ID: event-42 event-43 -------------------> replayed
The server decides which messages to replay. hx-sse processes every event it receives and does not deduplicate replays.
Trigger Client Events
An SSE event field dispatches a DOM event instead of swapping its data:
HTTP/1.1 200 OK Content-Type: text/event-stream event: progress data: 50 id: task-5
Handle it with hx-on:
<button hx-get="/progress" hx-on:progress="htmx.find('#progress').value = event.detail.data"> Start </button> <progress id="progress" max="100" value="0"></progress>
The event bubbles from the request source and exposes:
event.detail = { data: '50', id: 'task-5' }
A named event can also trigger another htmx request:
<div hx-get="/status" hx-trigger="progress from:body"></div>
Attributes
hx-get/hx-post/hx-put/…
The hx-sse extension enhances:
When a response uses Content-Type: text/event-stream, htmx processes each SSE event as it arrives.
hx-sse:connect
Opens a persistent SSE connection with a GET request:
<div hx-sse:connect="/events"></div>
The connection uses:
hx-headersandhx-valsfor the GET requesthx-target,hx-swap,hx-select, andhx-select-oobfor each message
Defaults:
hx-trigger="load"sse.reconnect:truesse.pauseOnBackground:truesse.releaseOn:immediateswapEmpty:falsefor each message
hx-sse:close
Closes the connection after a matching named event:
<div hx-sse:connect="/events" hx-sse:close="done"></div>
HTTP/1.1 200 OK Content-Type: text/event-stream event: done data: Complete
Headers
Accept
Advertises SSE support on every htmx request while the extension is loaded.
Accept: text/html, text/event-stream
A response is streamed when its Content-Type contains text/event-stream.
Last-Event-ID
Identifies the last received SSE event during reconnection.
Last-Event-ID: event-42
The extension sends this header while the current event ID is not empty. Events without id inherit that ID, while an empty id: removes the header from later reconnects.
The server decides how to replay later messages. The client does not deduplicate them.
Events
Event data is available on event.detail.
Connection events expose:
event.detail.connection = { url, config, lastEventId, attempt, status, cancelled }
Message events expose:
event.detail = { connection, message: { data, event, id }, waitUntil(), // before processing only cancelled // before processing only }
htmx:sse:before:connection
Fires before htmx starts the initial stream or schedules a reconnect.
document.addEventListener('htmx:sse:before:connection', event => { if (event.detail.connection.attempt > 5) event.preventDefault() })
The initial HTTP response has already arrived. Cancel either way:
- call
event.preventDefault() - set
event.detail.connection.cancelledtotrue
htmx:sse:after:connection
Fires after the initial response or a reconnect is ready to stream.
document.addEventListener('htmx:sse:after:connection', event => { console.log('Connected:', event.detail.connection.url) })
connection.status contains the HTTP status.
htmx:sse:before:message
Fires before processing an SSE message.
document.addEventListener('htmx:sse:before:message', event => { let message = event.detail.message if (message.event === 'heartbeat') event.preventDefault() else message.data = sanitize(message.data) })
Changing message.data changes the swap or named event data. Changing message.event changes whether the message swaps or dispatches a DOM event.
detail.waitUntil(promise) delays processing until asynchronous work finishes. Cancel with event.preventDefault() or detail.cancelled = true.
htmx:sse:after:message
Fires after htmx swaps or dispatches an SSE message.
document.addEventListener('htmx:sse:after:message', event => { console.log('Received:', event.detail.message.data) })
htmx:sse:close
Fires when an SSE stream closes.
document.addEventListener('htmx:sse:close', event => { console.log('Closed:', event.detail.reason) })
reason is one of:
message:hx-sse:closematched a named eventremoved: the source element left the DOMended: the stream ended or exhausted its reconnect attemptscancelled: the initial stream was cancelledcleanup: htmx cleaned up the source element
htmx:sse:error
Fires when reading or reconnecting to an SSE stream fails.
document.addEventListener('htmx:sse:error', event => { console.error('SSE error:', event.detail.error) })
connection: the failed connection, when availableurl: the SSE URL when setup fails before a connection existserror: the error valuestatus: the HTTP status for a failed reconnect response, when available
hx:release
A server-sent event that ends the request lifecycle early. Only useful with sse.releaseOn:end.
event: hx:release data:
When the server sends this event, htmx hides indicators and re-enables elements immediately. The stream continues running in the background.
This is useful for LLM streaming where you want to:
- Show a loading indicator until the model starts responding
- Let the user interact with the page while tokens continue streaming
<button hx-post="/generate" hx-target="#output" hx-swap="beforeend" hx-config="sse.releaseOn:end" hx-indicator="#spinner"> Generate </button>
The server streams:
data: First token event: hx:release data: data: more tokens...
The indicator hides after hx:release, but tokens keep appending.
Config
sse.reconnect
Control whether a closed stream reconnects automatically.
<meta name="htmx-config" content="sse.reconnect:false">
Defaults to true for hx-sse:connect and false for normal htmx requests.
sse.reconnectDelay
Set how long to wait before the first reconnect attempt.
<meta name="htmx-config" content="sse.reconnectDelay:1s">
Defaults to 500 milliseconds. Each failed attempt doubles the delay, and values may be milliseconds or time strings such as 500ms, 1s, and 2m.
The server can replace this value for the stream:
HTTP/1.1 200 OK Content-Type: text/event-stream retry: 2000 data: Reconnect after two seconds
sse.reconnectMaxDelay
Limit how long to wait between reconnect attempts.
<meta name="htmx-config" content="sse.reconnectMaxDelay:30s">
Defaults to 60000 milliseconds. Use milliseconds or a time string.
sse.reconnectMaxAttempts
Limit how many times a closed stream tries to reconnect.
<meta name="htmx-config" content="sse.reconnectMaxAttempts:5">
Defaults to Infinity.
sse.reconnectJitter
Spread reconnect attempts so many clients do not retry at once.
<meta name="htmx-config" content="sse.reconnectJitter:0">
Defaults to 0.3, which randomizes each delay by up to ±30%. Use 0 for exact delays.
sse.pauseOnBackground
Close the stream while the page is hidden and reconnect when it becomes visible.
<meta name="htmx-config" content="sse.pauseOnBackground:false">
Defaults to true for hx-sse:connect and false for normal htmx requests. Use event IDs and server-side replay to recover messages sent while disconnected.
sse.releaseOn
Control when the request lifecycle ends (indicators hide, elements re-enable).
<meta name="htmx-config" content="sse.releaseOn:first">
Values:
immediate: release when SSE takes over (after headers arrive)first: release after the first message swapsend: release when the stream closes
Defaults to immediate for hx-sse:connect and end for normal htmx requests.
With the default end, indicators stay visible and hx-disable keeps elements disabled until the stream closes. This works well for LLM streaming where you want to prevent duplicate submissions.
Use first if you want the UI to become interactive as soon as content starts arriving:
<button hx-post="/generate" hx-config="sse.releaseOn:first"> Generate </button>
The server can also release early by sending an hx:release event:
event: hx:release data:
Migration
htmx 2.0
htmx 2.0 used EventSource and selected named messages with sse-swap:
<div sse-connect="/chat" sse-swap="message"></div>
htmx 4 uses a normal htmx request and swaps unnamed messages automatically:
<div hx-sse:connect="/chat"></div>
HTTP/1.1 200 OK Content-Type: text/event-stream data: <p>New message</p>
Named messages now dispatch DOM events instead of selecting a swap target:
HTTP/1.1 200 OK Content-Type: text/event-stream event: progress data: 50
<div hx-sse:connect="/progress" hx-on:progress="updateProgress(event.detail.data)"> </div>
Trigger another request with the ordinary event name:
<!-- htmx 2 --> <div hx-get="/status" hx-trigger="sse:progress"></div> <!-- htmx 4 --> <div hx-get="/status" hx-trigger="progress from:body"></div>
Attributes
These attributes changed:
| htmx 2.x | htmx 4.x | Compatibility |
|---|---|---|
sse-connect | hx-sse:connect | Works with a warning |
sse-swap | Unnamed messages swap automatically | Removed; warns |
sse-close | hx-sse:close | Works with a warning |
Events
These events changed:
| htmx 2.x | htmx 4.x |
|---|---|
htmx:sseOpen | htmx:sse:after:connection |
htmx:sseError | htmx:sse:error |
htmx:sseBeforeMessage | htmx:sse:before:message |
htmx:sseMessage | htmx:sse:after:message |
htmx:sseClose | htmx:sse:close |
htmx 4 uses fetch() and ReadableStream instead of EventSource. SSE responses can therefore use any htmx HTTP method, request values, and headers.
Beta to RC1
RC1 namespaces SSE lifecycle events:
| Beta | RC1 |
|---|---|
htmx:before:sse:connection | htmx:sse:before:connection |
htmx:after:sse:connection | htmx:sse:after:connection |
htmx:before:sse:message | htmx:sse:before:message |
htmx:after:sse:message | htmx:sse:after:message |
Message cancellation moved from detail.message.cancelled to detail.cancelled. Before-message hooks can delay processing with detail.waitUntil(promise).