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

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:

PingPPoPonPong

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:

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.sse sets global defaults from JavaScript.

    htmx.config.sse.reconnectDelay = '1s' htmx.config.sse.reconnectMaxAttempts = 5
  • hx-config overrides 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:

Defaults:

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.cancelled to true

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:close matched a named event
  • removed: the source element left the DOM
  • ended: the stream ended or exhausted its reconnect attempts
  • cancelled: the initial stream was cancelled
  • cleanup: 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 URL
  • error: the error value
  • status: 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.xhtmx 4.xCompatibility
sse-connecthx-sse:connectWorks with a warning
sse-swapUnnamed messages swap automaticallyRemoved; warns
sse-closehx-sse:closeRemoved

Events

These events changed:

htmx 2.xhtmx 4.x
htmx:sseOpenhtmx:after:sse:connection
htmx:sseErrorhtmx:sse:error
htmx:sseBeforeMessagehtmx:before:sse:message
htmx:sseMessagehtmx:after:sse:message
htmx:sseClosehtmx:sse:close

htmx 4 uses fetch() and ReadableStream instead of EventSource. SSE responses can therefore use any htmx HTTP method, request values, and headers.