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

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>

The stream keeps the current event ID:

  • An event without id inherits 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:

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

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: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) })
  • connection: the failed connection, when available
  • url: the SSE URL when setup fails before a connection exists
  • error: the error value
  • status: 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 swaps
  • end: 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.xhtmx 4.xCompatibility
sse-connecthx-sse:connectWorks with a warning
sse-swapUnnamed messages swap automaticallyRemoved; warns
sse-closehx-sse:closeWorks with a warning

Events

These events changed:

htmx 2.xhtmx 4.x
htmx:sseOpenhtmx:sse:after:connection
htmx:sseErrorhtmx:sse:error
htmx:sseBeforeMessagehtmx:sse:before:message
htmx:sseMessagehtmx:sse:after: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.

Beta to RC1

RC1 namespaces SSE lifecycle events:

BetaRC1
htmx:before:sse:connectionhtmx:sse:before:connection
htmx:after:sse:connectionhtmx:sse:after:connection
htmx:before:sse:messagehtmx:sse:before:message
htmx:after:sse:messagehtmx:sse:after:message

Message cancellation moved from detail.message.cancelled to detail.cancelled. Before-message hooks can delay processing with detail.waitUntil(promise).