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:
hx-selectto select content for the swaphx-select-oobto select more elements to swap
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 swaptarget: where to swap itswap: a serializedhx-swapspecificationselect: what to select fromcontent
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 field | HTTP response header | Element default |
|---|---|---|
target | HX-Retarget | hx-target |
swap | HX-Reswap | hx-swap |
select | HX-Reselect | hx-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.wssets global defaults from JavaScript.htmx.config.ws.reconnectDelay = '1s' htmx.config.ws.reconnectMaxAttempts = 5 -
hx-configoverrides 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:
hx-target: defaults to the connection elementhx-swap: defaults tohtmx.config.defaultSwaphx-select: selects content for the connection’s swaphx-select-oob: selects more elements to swap
Defaults:
swapEmpty:false; set it explicitly inhx-swapto override ithx-trigger="load"; usehx-triggerto change itws.reconnect:truews.pauseOnBackground:true
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:
changefor text inputs,<textarea>, and<select>submitfor<form>clickfor 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.cancelledtotrue
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 metadatamessage.values: mutable form values andhx-valsmessage.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.cancelledtotrue
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, orcancelledcode: the WebSocket close code, ornull
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 availableurl: the requested URL when no connection existserror: 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 Away1005: No Status Received1006: Abnormal Closure1011: Internal Error1012: Service Restart1013: Try Again Later1014: 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:
hx-swap-oobor<hx-partial>for extra swaps- JSON to choose the connection’s target and 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.x | htmx 4.x |
|---|---|
ws-connect | hx-ws:connect |
ws-send | hx-ws:send |
htmx.config.wsReconnectDelay | htmx.config.ws.reconnectDelay |
createWebSocket | Removed |
wsBinaryType | Removed |
socketWrapper | Removed |
ws-connect and ws-send still work with a warning.
Events
These events changed:
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
| Beta | RC1 |
|---|---|
htmx:before:ws:connection | htmx:ws:before:connection |
htmx:after:ws:connection | htmx:ws:after:connection |
htmx:before:ws:request | htmx:ws:before:message:outgoing |
htmx:after:ws:request | htmx:ws:after:message:outgoing |
htmx:before:ws:message | htmx:ws:before:message:incoming |
htmx:after:ws:message | htmx: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
| Beta | RC1 | Compatibility |
|---|---|---|
htmx.config.websockets | htmx.config.ws | Removed |
ws.pendingRequestTTL | Removed | Removed |
ws.reconnectJitter:true/false | ws.reconnectJitter:0.3/0 | Removed |
payload | content | Works with a warning |
Notes
-
hx-ws:connectaccepts:- 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.
- Root-relative URLs:
-
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-connectandhx-ws-sendwhen colons are not supported, such as in JSX.