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-beta6/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta6/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>
On reconnect, hx-sse automatically includes the Last-Event-ID header:
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
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:trueswapEmpty: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 after a message supplies an id field. The server decides how to replay later messages.
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.message = { data, event, id, cancelled // before processing only }
htmx:before:sse:connection
Fires before htmx starts the initial stream or schedules a reconnect.
document.addEventListener('htmx:before:sse: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:after:sse:connection
Fires after the initial response or a reconnect is ready to stream.
document.addEventListener('htmx:after:sse:connection', event => { console.log('Connected:', event.detail.connection.url) })
connection.status contains the HTTP status.
htmx:before:sse:message
Fires before processing an SSE message.
document.addEventListener('htmx:before:sse: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.
htmx:after:sse:message
Fires after htmx swaps or dispatches an SSE message.
document.addEventListener('htmx:after:sse: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) })
url: the SSE URLerror: the error valuestatus: the HTTP status for a failed reconnect response, when available
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.
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 | Removed |
Events
These events changed:
| htmx 2.x | htmx 4.x |
|---|---|
htmx:sseOpen | htmx:after:sse:connection |
htmx:sseError | htmx:sse:error |
htmx:sseBeforeMessage | htmx:before:sse:message |
htmx:sseMessage | htmx:after:sse: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.