The hx-ws extension opens WebSocket connections, swaps incoming HTML, and sends form data as JSON.

If you used ws 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-ws.min.js"></script>

Usage

Update an Element

Open a persistent WebSocket connection:

<div hx-ws:connect="/chat"> ... </div>

The browser receives this WebSocket message:

<p>New message</p>

The result is:

<div hx-ws:connect="/chat"> <p>New message</p> <!-- Swapped in --> </div>

htmx uses the same rules as with a text/html response:

Choose the Swap

Use hx-swap and hx-target to choose how and where updates swap:

<div hx-ws:connect="/chat" hx-target="#messages" hx-swap="beforeend"> <div id="messages"> <p>Old message</p> <!-- Content goes here --> </div> </div>

The server sends:

<p>New message</p>

The result is:

<div hx-ws:connect="/chat" hx-target="#messages" hx-swap="beforeend"> <div id="messages"> <p>Old message</p> <p>New message</p> <!-- Appended --> </div> </div>

You can also use:

Update Elements

Start with the page elements to update:

<div hx-ws:connect="/chat"></div> <div id="feed"> <p>Old</p> </div> <div id="status">Offline</div>

We’ll send an hx-swap-oob element and an <hx-partial> (new in 4.0):

<!-- Match by ID --> <div id="status" hx-swap-oob="true">Online</div> <!-- Append --> <hx-partial hx-target="#feed" hx-swap="beforeend"> <p>New</p> </hx-partial>

The page becomes:

<div hx-ws:connect="/chat"></div> <div id="feed"> <p>Old</p> <p>New</p> </div> <div id="status">Online</div>
Why wasn't the normal swap used?

After htmx extracts extra swaps, the normal swap may be empty:

(empty)

hx-swap-oob and <hx-partial> elements are extracted before the normal swap. By default, swapEmpty:false leaves the connection unchanged.

The server can also mix these updates with ordinary HTML:

<p>New chat content</p> <!-- Also replace #status --> <hx-partial hx-target="#status">Busy</hx-partial>

The first element uses the connection’s target and swap. The partial updates #status.

To disable the connection’s swap, set hx-swap="none":

<div hx-ws:connect="/chat" hx-swap="none"> ... </div>

hx-swap-oob and <hx-partial> swaps still run.

Send a Message

Add hx-ws:send to an input inside the connection:

<div hx-ws:connect="/chat" hx-target="#messages"> <div id="messages"></div> <input hx-ws:send type="button" name="message" value="Hello"> </div>

The outgoing message is:

{ "headers": { "HX-Request": "true", "HX-Request-Type": "partial", "HX-Source": "input", "HX-Target": "div#messages", "HX-Current-URL": "https://example.com/chat" }, "message": "Hello" }

headers is reserved for metadata. Form values and hx-vals use the other top-level keys.

Repeat a form field to send an array:

<form hx-ws:send> <input name="tag" value="urgent"> <input name="tag" value="public"> <button>Send</button> </form>

The outgoing message is:

{ "headers": { /* ... */ }, "tag": ["urgent", "public"] }

hx-vals overrides form values without coercing its types:

<form hx-ws:send hx-vals="count:2"> <input name="count" value="1"> <button>Send</button> </form>
{ "headers": { /* ... */ }, "count": 2 }

Override an Incoming Swap

Use JSON to override the connection’s swap:

{ "content": "<p class=\"message\">New message</p>", "target": "#messages", "swap": "beforeend settle:10ms", "select": ".message" }
  • content: the HTML to swap
  • target: where to swap it
  • swap: a serialized hx-swap specification
  • select: what to select from content

HTTP HX-Re* headers replace values already chosen for a request.

A WebSocket message may arrive without a request, so its JSON fields can choose those values from the start:

JSON fieldHTTP response headerElement default
targetHX-Retargethx-target
swapHX-Reswaphx-swap
selectHX-Reselecthx-select

content uses the same hx-target, hx-swap, and hx-select attributes as plain HTML. hx-swap-oob and <hx-partial> inside it still produce independent swaps.

The JSON fields override the corresponding attributes:

TARGET JSON target --> hx-target --> connection element SWAP JSON swap --> hx-swap --> defaultSwap SELECT JSON select --> hx-select --> all content

hx-select-oob remains an element setting. A server can use hx-swap-oob or <hx-partial> inside content instead.

Handle Custom Messages

JSON without content is not swapped:

{ "type": "notification", "text": "New message" }

Handle it with htmx:ws:before:message:incoming:

document.addEventListener('htmx:ws:before:message:incoming', async event => { let message = await event.detail.message.json() if (message.type === 'notification') showNotification(message) })

Cancel the event to take over custom or binary processing:

document.addEventListener('htmx:ws:before:message:incoming', async event => { event.preventDefault() handleCustomMessage(await event.detail.message.text()) })

message.data starts as the original string, Blob, or ArrayBuffer.

Convert its current value:

await message.text() await message.json()

Cancel to skip built-in handling. Binary messages are not swapped automatically.

Persistent Connections

WebSocket connections stay open for incoming and outgoing messages.

Open Connections

Use hx-trigger to open a connection later than load:

<button id="connect">Connect</button> <div hx-ws:connect="/chat" hx-trigger="click from:#connect"> </div>

All hx-trigger modifiers are supported.

Open When Sending

Put both attributes on one element to open its connection when it sends:

<button hx-ws:connect="/actions" hx-ws:send hx-trigger="click" name="action" value="refresh"> Refresh </button>

Clicking the button opens /actions and sends action=refresh over that connection.

Send During Reconnect

A user can trigger messages while a connection is reconnecting:

<div hx-ws:connect="/actions"> <button hx-ws:send name="action" value="save">Save</button> <button hx-ws:send name="action" value="refresh">Refresh</button> </div>

If the user clicks Save, then Refresh, htmx sends both when the connection opens:

// First { "headers": { /* ... */ }, "action": "save" } // Then { "headers": { /* ... */ }, "action": "refresh" }
Use Shared Connections

Put several hx-ws:send elements inside one hx-ws:connect:

<div hx-ws:connect="/actions"> <button hx-ws:send name="action" value="save">Save</button> <button hx-ws:send name="action" value="delete">Delete</button> </div> <div id="save-result"></div> <div id="delete-result"></div>

Both buttons use the connection owned by the <div>. Incoming messages use the <div>, not the button that sent the message.

Route Incoming Messages

Set target in an incoming JSON message to direct its swap:

{ "content": "<p>Saved</p>", "target": "#save-result" }

Use hx-swap-oob or <hx-partial> when one message updates several targets.

Close Connections

Close with code 1000 to stop reconnecting:

socket.close(1000, 'done')

Codes in ws.reconnectCodes reconnect instead.

Configure Connections

You can configure hx-ws in three places:

  • <meta name="htmx-config"> sets global defaults from HTML.

    <meta name="htmx-config" content="ws.reconnectDelay:1s ws.reconnectMaxAttempts:5">
  • htmx.config.ws sets global defaults from JavaScript.

    htmx.config.ws.reconnectDelay = '1s' htmx.config.ws.reconnectMaxAttempts = 5
  • hx-config overrides the defaults for one connection.

    <div hx-ws:connect="/ws" hx-config="ws.reconnectMaxAttempts:2"> </div>

These values are read when the connection is created.

Attributes

hx-ws:connect

Opens a WebSocket connection:

<div hx-ws:connect="/chat"></div>

Incoming HTML uses these inherited swap attributes:

Defaults:

Each hx-ws:connect element owns its connection. Separate elements open separate connections, even when they use the same URL.

hx-ws:send

Sends form data and hx-vals as JSON.

<div hx-ws:connect="/chat"> <form id="chat-form" hx-ws:send hx-target="#messages"> <input name="message"> <button>Send</button> </form> <div id="messages"></div> </div>

hx-ws:send uses the nearest hx-ws:connect, including one on the same element. Its value is ignored.

Default hx-trigger:

  • change for text inputs, <textarea>, and <select>
  • submit for <form>
  • click for buttons and other elements

Events

Event data is available on event.detail.

Connection and close events:

event.detail.connection = { url, config, socket, // WebSocket or null queue, // outgoing messages waiting to send attempt, // reconnect count cancelled }

Incoming message events:

event.detail = { connection, message: { data, // current string, Blob, or ArrayBuffer text(), json() }, waitUntil(), // before processing only cancelled // before processing only }

Outgoing message events expose:

event.detail = { connection, message: { headers, // htmx metadata values, // form values and hx-vals data // replacement payload before send; sent payload afterward }, waitUntil(), // before sending only cancelled // before sending only }

Incoming message events dispatch from the connection-owning element. Outgoing message events dispatch from the sending element.

htmx:ws:before:connection

Fires before the initial connection and each reconnect.

document.addEventListener('htmx:ws:before:connection', event => { event.detail.connection.config.protocols = 'graphql-transport-ws' })

Cancel either way:

  • call event.preventDefault()
  • set event.detail.connection.cancelled to true

htmx:ws:after:connection

Fires after a connection opens.

document.addEventListener('htmx:ws:after:connection', event => { event.detail.connection.socket.binaryType = 'arraybuffer' })

htmx:ws:before:message:outgoing

Fires before sending an outgoing message.

document.addEventListener('htmx:ws:before:message:outgoing', event => { let message = event.detail.message message.headers.Authorization = `Bearer ${token}` if (!isValid(message.values)) event.preventDefault() })
  • message.headers: mutable htmx metadata
  • message.values: mutable form values and hx-vals
  • message.data: optional replacement payload

detail.waitUntil(promise) delays serialization and sending until asynchronous work finishes.

The normal path serializes {...values, headers} as JSON. Set message.data to send a string, Blob, ArrayBuffer, or typed-array view instead:

document.addEventListener('htmx:ws:before:message:outgoing', event => { let message = event.detail.message message.data = encodeMessagePack({ ...message.values, headers: message.headers }) })

Include message.headers in replacement data when the server needs htmx request metadata.

htmx:ws:after:message:outgoing

Fires after sending an outgoing message.

document.addEventListener('htmx:ws:after:message:outgoing', event => { console.log('Outgoing:', event.detail.message.data) })

message.data is the value passed to WebSocket.send().

htmx:ws:before:message:incoming

Fires before processing an incoming message.

document.addEventListener('htmx:ws:before:message:incoming', event => { let { message, waitUntil } = event.detail waitUntil(message.json().then(data => { if (!isValid(data)) event.detail.cancelled = true })) })

detail.waitUntil(promise) delays built-in processing until asynchronous work finishes.

Set message.data to replace the data before built-in processing. Changes made through waitUntil() apply too.

Cancel synchronous processing either way:

  • call event.preventDefault()
  • set event.detail.cancelled to true

htmx:ws:after:message:incoming

Fires after the extension handles an incoming message.

document.addEventListener('htmx:ws:after:message:incoming', event => { console.log('Incoming:', event.detail.message.data) })

htmx:ws:close

Fires when a connection closes.

document.addEventListener('htmx:ws:close', event => { console.log('Closed:', event.detail.reason, event.detail.code) })
  • reason: closed, removed, or cancelled
  • code: the WebSocket close code, or null

A code in ws.reconnectCodes schedules a reconnect when ws.reconnect:true.

htmx:ws:error

Fires on connection and send errors.

document.addEventListener('htmx:ws:error', event => { console.error('WebSocket error:', event.detail.error) })
  • connection: the connection that failed, when available
  • url: the requested URL when no connection exists
  • error: the error value

Config

ws.reconnect

Control whether allowed close codes reconnect automatically.

<meta name="htmx-config" content="ws.reconnect:false">

Defaults to true.

ws.reconnectCodes

Choose which WebSocket close codes reconnect:

<meta name="htmx-config" content='{"ws":{"reconnectCodes":[1001,1005,1006,1011,1012,1013,1014]}}'>

Defaults to:

  • 1001: Going Away
  • 1005: No Status Received
  • 1006: Abnormal Closure
  • 1011: Internal Error
  • 1012: Service Restart
  • 1013: Try Again Later
  • 1014: Bad Gateway

See MDN’s close code descriptions and the IANA close code registry.

ws.reconnectDelay

Set how long to wait before the first reconnect attempt.

<meta name="htmx-config" content="ws.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.

ws.reconnectMaxDelay

Limit how long to wait between reconnect attempts.

<meta name="htmx-config" content="ws.reconnectMaxDelay:30s">

Defaults to 60000 milliseconds. Use milliseconds or a time string.

ws.reconnectMaxAttempts

Limit how many times a closed connection tries to reconnect.

<meta name="htmx-config" content="ws.reconnectMaxAttempts:5">

Defaults to Infinity.

ws.reconnectJitter

Spread reconnect attempts so many clients do not retry at once.

<meta name="htmx-config" content="ws.reconnectJitter:0">

Defaults to 0.3, which randomizes each delay by up to ±30%. Use 0 for exact delays.

ws.maxOutgoingMessagesQueueSize

Limit how many outgoing messages can wait during reconnect.

<meta name="htmx-config" content="ws.maxOutgoingMessagesQueueSize:20">

Defaults to 100. Further messages fire htmx:ws:error and are not sent. Use 0 to disable queuing.

ws.pauseOnBackground

Close connections while the page is hidden and reconnect when it becomes visible.

<meta name="htmx-config" content="ws.pauseOnBackground:false">

Defaults to true.

ws.protocols

Set WebSocket subprotocols for the handshake.

<meta name="htmx-config" content="ws.protocols:graphql-transport-ws">

No subprotocol is set by default. Use JSON config to set several subprotocols.

Migration

htmx 2.0

htmx 2.0 treats every incoming element as an implicit hx-swap-oob:

<!-- interpreted as hx-swap-oob="true" by default --> <div id="notifications"> New message </div>

htmx 4.0 uses hx-target and hx-swap on the connection:

<div hx-ws:connect="/notifications" hx-target="#notifications" hx-swap="outerHTML"> </div> <div id="notifications"></div>

The incoming message contains plain HTML:

<div id="notifications"> New message </div>

htmx 4.0 requires explicit syntax for each extra swap:

Outgoing Messages

htmx 2 added HEADERS to the form values:

{ "message": "Hello", "HEADERS": { "HX-Request": "true" } }

htmx 4 reserves headers for metadata and puts values at the top level:

{ "headers": { "HX-Request": "true" }, "message": "Hello" }

Attributes and APIs

These names changed:

htmx 2.xhtmx 4.x
ws-connecthx-ws:connect
ws-sendhx-ws:send
htmx.config.wsReconnectDelayhtmx.config.ws.reconnectDelay
createWebSocketRemoved
wsBinaryTypeRemoved
socketWrapperRemoved

ws-connect and ws-send still work with a warning.

Events

These events changed:

htmx 2.xhtmx 4.x
htmx:wsConnectingRemoved
htmx:wsOpenhtmx:ws:after:connection
htmx:wsClosehtmx:ws:close
htmx:wsErrorhtmx:ws:error
htmx:wsBeforeMessagehtmx:ws:before:message:incoming
htmx:wsAfterMessagehtmx:ws:after:message:incoming
htmx:wsConfigSendhtmx:ws:before:message:outgoing
htmx:wsBeforeSendhtmx:ws:before:message:outgoing
htmx:wsAfterSendhtmx:ws:after:message:outgoing

Beta to RC1

RC1 changes the beta message formats, events, and reconnect behavior.

Outgoing Messages

The wire format changed from {headers, body: values} to {...values, headers}. headers is reserved, so a form field or hx-vals entry with that name is not sent.

Event detail changed from {headers, body} to {message: {headers, values, data}, waitUntil, cancelled}. Set message.data to replace the payload. Use waitUntil(promise) to finish asynchronous work before serialization, queueing, or sending.

Incoming Messages

Message correlation through HX-Request-ID and request_id was removed.

Event detail changed from {message: {text, json, cancelled}} to {message: {data, text(), json()}, waitUntil, cancelled}. The conversion fields are now methods. Set message.data to replace incoming data. Cancel with event.preventDefault() or event.detail.cancelled = true.

Events

BetaRC1
htmx:before:ws:connectionhtmx:ws:before:connection
htmx:after:ws:connectionhtmx:ws:after:connection
htmx:before:ws:requesthtmx:ws:before:message:outgoing
htmx:after:ws:requesthtmx:ws:after:message:outgoing
htmx:before:ws:messagehtmx:ws:before:message:incoming
htmx:after:ws:messagehtmx:ws:after:message:incoming

htmx:ws:error exposes connection when the failure belongs to one. It exposes url when no connection exists.

Reconnection

The beta reconnected after every close. RC1 reconnects only when ws.reconnect is true, the close code is in ws.reconnectCodes, and a connected element remains.

Code 1000 stops reconnecting by default. Messages created while a connection opens or reconnects are queued and sent in order.

Connection Ownership

Each hx-ws:connect element owns its connection. Separate elements no longer share a connection by URL.

An hx-ws:send value no longer opens a connection and is ignored. Put hx-ws:send inside an hx-ws:connect, or put both attributes on one element.

Other Changes

BetaRC1Compatibility
htmx.config.websocketshtmx.config.wsRemoved
ws.pendingRequestTTLRemovedRemoved
ws.reconnectJitter:true/falsews.reconnectJitter:0.3/0Removed
payloadcontentWorks with a warning

Notes

  • hx-ws:connect accepts:

    • Root-relative URLs: /ws
    • Path-relative URLs: events
    • Protocol-relative URLs: //api.example.com/ws
    • HTTP(S) URLs: https://example.com/ws
    • WebSocket URLs: wss://example.com/ws

    HTTP(S) URLs are converted to their WebSocket equivalents.

  • Each connection processes incoming and outgoing messages in order. Incoming processing waits for each swap to finish.

  • All WebSocket swaps use htmx.swap().

  • Use hx-ws-connect and hx-ws-send when colons are not supported, such as in JSX.

See Also