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-beta6/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta6/dist/ext/hx-ws.min.js"></script>
Usage
Update an Element
Open a persistent WebSocket connection:
<div hx-ws:connect="/chat" hx-target="this"> ... </div>
The browser receives this WebSocket message:
<p>New message</p>
The result is:
<div hx-ws:connect="/chat" hx-target="this"> <p>New message</p> <!-- Swapped in --> </div>
The explicit target enables the normal swap. htmx uses:
Without an element or JSON target, plain incoming HTML uses swap:none. Explicit hx-swap-oob and <hx-partial> swaps still run.
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>
Update Elements
Use explicit extra swaps to update several elements:
<div hx-ws:connect="/chat" hx-swap="none"></div> <div id="feed"> <p>Old</p> </div> <div id="status">Offline</div>
The server sends an hx-swap-oob element and an <hx-partial>:
<!-- 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" hx-swap="none"></div> <div id="feed"> <p>Old</p> <p>New</p> </div> <div id="status">Online</div>
hx-swap="none" disables the connection element’s normal swap. The explicit extra swaps still run.
Send a Message
Add hx-ws:send to a form inside the connection:
<div hx-ws:connect="/chat" hx-target="#messages"> <div id="messages"></div> <form hx-ws:send> <input name="message" value="Hello"> <button>Send</button> </form> </div>
The outgoing message is:
{ "headers": { "HX-Request": "true", "HX-Request-ID": "550e8400-e29b-41d4-a716-446655440000", "HX-Request-Type": "partial", "HX-Source": "form", "HX-Target": "div#messages", "HX-Current-URL": "https://example.com/chat" }, "body": { "message": "Hello" } }
headers contains htmx metadata. body contains form values and hx-vals.
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>
{ "headers": { /* ... */ }, "body": { "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": { /* ... */ }, "body": { "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" }
content: the HTML to swaptarget: where to swap itswap: a serializedhx-swapspecificationHX-Request-ID: an optional top-level sender correlation IDrequest_id: a supported legacy correlation ID
The JSON fields override the corresponding attributes:
TARGET JSON target --> hx-target --> connection element SWAP JSON swap --> hx-swap --> defaultSwap when a target is set
hx-swap-oob and <hx-partial> inside content still produce independent swaps.
Handle Custom Messages
JSON without content is not swapped:
{ "type": "notification", "text": "New message" }
Handle it with htmx:before:ws:message:
document.addEventListener('htmx:before:ws:message', event => { let message = event.detail.message.json if (message?.type === 'notification') showNotification(message) })
The event exposes:
message.text: the original textmessage.json: the parsed object, ornullmessage.cancelled: set totrueto skip built-in handling
You can also call event.preventDefault() to take over processing.
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 a Connection with hx-ws:send
Give hx-ws:send a URL to open a connection:
<button hx-ws:send="/actions" hx-vals="action:refresh"> Refresh </button>
Clicking the button opens /actions and sends the values over that connection.
Use Shared Connections
Put several hx-ws:send elements inside one hx-ws:connect:
<div hx-ws:connect="/actions"> <button hx-ws:send hx-vals="action:save" hx-target="#save-result">Save</button> <button hx-ws:send hx-vals="action:delete" hx-target="#delete-result">Delete</button> </div> <div id="save-result"></div> <div id="delete-result"></div>
Both buttons use the same WebSocket connection. Copy an outgoing HX-Request-ID into the top level of its incoming message to use the sending button’s target:
{ "HX-Request-ID": "550e8400-e29b-41d4-a716-446655440000", "content": "<p>Saved</p>" }
Without the ID, a live connection element handles the message.
Separate hx-ws:connect elements with the same URL also share one connection:
<header hx-ws:connect="/actions"></header> <main hx-ws:connect="/actions"></main>
The connection closes when htmx removes its last element.
Close Connections
By default, a WebSocket close schedules a reconnect. Set ws.reconnect:false when the connection should remain closed:
<div hx-ws:connect="/one-shot" hx-config="ws.reconnect:false"></div>
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" hx-target="#messages"></div>
Incoming HTML uses:
hx-target: enables the normal swap and chooses its targethx-swap: defaults tohtmx.config.defaultSwapwhen a target is set
Without an element or JSON target, ordinary incoming HTML uses swap:none. Explicit hx-swap-oob and <hx-partial> swaps still run.
Other defaults:
Elements using the same normalized URL share one connection.
hx-ws:send
Sends form data and hx-vals as {headers, body} 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: use the nearest ancestor connectionhx-ws:send="<url>": open a direct connection
Default hx-trigger:
changefor inputs other than button and submit inputs, plus<textarea>and<select>submitfor<form>clickfor button and submit inputs, plus other elements
Headers
HX-Request-ID
Associates an incoming message with its outgoing sender.
// Browser to server { "headers": { "HX-Request-ID": "abc123" }, "body": { "action": "save" } } // Server to browser { "HX-Request-ID": "abc123", "content": "<p>Saved</p>" }
hx-ws adds a unique ID to every outgoing message. Copy it from the outgoing headers object to the incoming message’s top level to use the sender’s target and swap attributes.
Events
Event data is available on event.detail.
htmx:before:ws:connection
Fires before the initial connection and each reconnect.
document.addEventListener('htmx:before:ws:connection', event => { event.detail.connection.config.protocols = 'graphql-transport-ws' })
Cancel either way:
- call
event.preventDefault() - set
event.detail.connection.cancelledtotrue
htmx:after:ws:connection
Fires after a connection opens.
document.addEventListener('htmx:after:ws:connection', event => { console.log('Connected:', event.detail.connection.url) })
Connection events expose the internal connection state, including url, config, socket, attempt, cancelled, and pendingRequests.
htmx:before:ws:request
Fires before sending an outgoing message.
document.addEventListener('htmx:before:ws:request', event => { event.detail.headers.Authorization = `Bearer ${token}` if (!isValid(event.detail.body)) event.preventDefault() })
detail.headers and detail.body are mutable.
htmx:after:ws:request
Fires after sending an outgoing message.
document.addEventListener('htmx:after:ws:request', event => { console.log('Outgoing:', event.detail.body) })
htmx:before:ws:message
Fires before processing an incoming text message.
document.addEventListener('htmx:before:ws:message', event => { let message = event.detail.message if (message.json?.type === 'heartbeat') event.preventDefault() })
Cancel either way:
- call
event.preventDefault() - set
event.detail.message.cancelledtotrue
htmx:after:ws:message
Fires after the extension handles an incoming message.
document.addEventListener('htmx:after:ws:message', event => { console.log('Incoming:', event.detail.message.text) })
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
When ws.reconnect is true, any WebSocket close code schedules a reconnect while a connected element remains. Background pausing waits until the page becomes visible.
htmx:ws:error
Fires on connection and send errors.
document.addEventListener('htmx:ws:error', event => { console.error('WebSocket error:', event.detail.error) })
url: the WebSocket URL, ornullerror: the error value
Config
ws.reconnect
Control whether a closed connection reconnects automatically.
<meta name="htmx-config" content="ws.reconnect:false">
Defaults to true.
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. 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.
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 or false for exact delays. true uses the default 0.3 factor.
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.pendingRequestTTL
Set how long hx-ws remembers an outgoing message so an incoming message can use its sender.
<meta name="htmx-config" content="ws.pendingRequestTTL:60000">
Defaults to 30000 milliseconds. After it expires, the incoming message uses a live connection element.
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. htmx 4 uses hx-target and hx-swap on the connection, or explicit hx-swap-oob and <hx-partial> elements.
Outgoing Messages
htmx 2 added HEADERS to the form values:
{ "message": "Hello", "HEADERS": { "HX-Request": "true" } }
htmx 4 separates metadata and values:
{ "headers": { "HX-Request": "true" }, "body": { "message": "Hello" } }
Attributes and APIs
| 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
htmx 4.0 Alpha
Early htmx 4 builds used different names:
| Early htmx 4 | Current | Compatibility |
|---|---|---|
htmx.config.websockets | htmx.config.ws | Removed |
payload | content | Works with a warning |
incoming request_id | incoming HX-Request-ID | Still supported |
Boolean ws.reconnectJitter values remain supported. true means 0.3; false means 0.
Notes
hx-ws:connectaccepts root-relative, path-relative, protocol-relative, HTTP(S), and WebSocket URLs. HTTP(S) URLs are converted to WebSocket URLs.- A send triggered while the initial socket opens waits for that socket. A send triggered while reconnecting reports
Connection not open. - All WebSocket swaps use
htmx.swap(). - Use
hx-ws-connectandhx-ws-sendwhen colons are not supported, such as in JSX.