The WebSockets extension provides bidirectional real-time communication for htmx, following the same patterns established by the built-in SSE (Server-Sent Events) streaming support.
- Consistency with SSE - Incoming messages use the same swapping behavior (
hx-target,hx-swap,<hx-partial>) - Leverage existing attributes - Reuse
hx-vals,hx-include,hx-trigger,hx-config - Clear semantics - WebSocket operations distinct from HTTP
- Progressive complexity - Simple cases work with minimal config, advanced features available when needed
Establishes a WebSocket connection to the specified URL.
Example:
<div hx-ws:connect="/chat" hx-trigger="load">
<div id="messages"></div>
</div>With configuration:
<div hx-ws:connect="/chat"
hx-trigger="load"
hx-config='{"websockets": {"reconnect": true, "reconnectMaxAttempts": 5}}'>
</div>Notes:
- Trigger behavior follows standard htmx rules (no special default for WebSockets)
- Use
hx-trigger="load"to connect when element appears - Incoming messages swap into element (respects
hx-target,hx-swap)
Sends data over the WebSocket connection.
Behavior:
- Finds closest ancestor with
hx-ws:connectattribute - Collects form data or uses
hx-vals - Sends as JSON over the WebSocket
- Works with all existing htmx attributes:
hx-vals,hx-include,hx-trigger
Example:
<div hx-ws:connect="/chat" hx-trigger="load">
<!-- Send form data -->
<form hx-ws:send>
<input name="message" placeholder="Type message...">
<button>Send</button>
</form>
<!-- Send with hx-vals -->
<button hx-ws:send
hx-vals='{"action": "refresh"}'>
Refresh
</button>
</div>Incoming WebSocket messages are treated as responses and follow the same swapping rules as SSE:
<!-- Server sends HTML -->
<div>New message content</div>
<!-- Swaps into target using hx-swap strategy --><!-- Server sends targeted updates -->
<hx-partial hx-target="#messages" hx-swap="beforeend">
<div class="message">
<strong>Alice:</strong> Hello!
</div>
</hx-partial>
<hx-partial hx-target="#user-count" hx-swap="innerHTML">
<span>5 users online</span>
</hx-partial>This allows multiple independent updates from a single WebSocket message.
WebSocket connections use the websockets configuration object (same structure as streams):
htmx.config.websockets = {
autoConnect: true, // Automatically add hx-trigger="load" to hx-ws:connect elements
reconnect: true, // Enable automatic reconnection
reconnectDelay: 500, // Initial reconnect delay (ms)
reconnectMaxDelay: 30000, // Maximum backoff delay (ms)
reconnectMaxAttempts: Infinity, // Max reconnection attempts
reconnectJitter: 0, // Jitter factor (0-1, adds randomness to backoff)
pauseInBackground: false // Pause when tab is hidden/backgrounded
}<div hx-ws:connect="/chat"
hx-trigger="load"
hx-config='{"websockets": {
"reconnect": true,
"reconnectMaxAttempts": 5,
"reconnectDelay": 1000,
"pauseInBackground": true
}}'>
</div><meta name="htmx:config" content='{
"websockets": {
"autoConnect": true,
"reconnect": true,
"reconnectMaxAttempts": 10,
"reconnectDelay": 1000,
"reconnectMaxDelay": 60000,
"reconnectJitter": 0.2,
"pauseInBackground": false
}
}'>WebSockets default to reconnect: true because:
- WebSockets are designed for persistent connections
- Breaking the connection defeats the purpose
- Matches developer expectations (Socket.IO reconnects by default)
SSE defaults to reconnect: false because:
- One-shot streaming is common (LLM responses, progress bars)
- Continuous mode is opt-in for real-time feeds
WebSocket reconnection uses the same configuration and algorithm as SSE streams. See the SSE Streaming documentation for details on how reconnect, reconnectDelay, reconnectMaxDelay, reconnectMaxAttempts, reconnectJitter, and pauseInBackground work.
The reconnection algorithm implements exponential backoff with optional jitter to prevent thundering herd problems.
<!-- Connects automatically on load due to autoConnect: true default -->
<div hx-ws:connect="/chat">
<!-- Messages display area -->
<div id="chat-messages"></div>
<!-- Send message form -->
<form hx-ws:send hx-trigger="submit" hx-on:htmx:after:send="this.reset()">
<input name="message" placeholder="Type message...">
<button>Send</button>
</form>
</div>Server sends:
<hx-partial hx-target="#chat-messages" hx-swap="beforeend">
<div class="message">
<strong>Alice:</strong> Hello!
</div>
</hx-partial>Client sends:
{"message": "Hello!"}<div hx-ws:connect="/metrics">
<!-- Multiple independent metric displays -->
<div id="cpu">Loading...</div>
<div id="memory">Loading...</div>
<div id="disk">Loading...</div>
<!-- Send command -->
<button hx-ws:send
hx-vals='{"action": "refresh"}'
hx-trigger="click">
Refresh All
</button>
</div>Server sends:
<hx-partial hx-target="#cpu" hx-swap="innerHTML">
CPU: 45%
</hx-partial>
<hx-partial hx-target="#memory" hx-swap="innerHTML">
Memory: 2.1GB
</hx-partial>
<hx-partial hx-target="#disk" hx-swap="innerHTML">
Disk: 128GB free
</hx-partial><!-- Explicit trigger overrides autoConnect setting -->
<button hx-ws:connect="/live-prices" hx-trigger="click">
Watch Live Prices
</button>When an explicit hx-trigger is present, it takes precedence over the autoConnect setting. Connection remains active (reconnects automatically based on config) until element is removed from DOM.
<div hx-ws:connect="/api">
<!-- Send with multiple data sources -->
<button hx-ws:send
hx-vals='{"action": "update", "priority": "high"}'
hx-include="#user-settings"
hx-trigger="click">
Update Settings
</button>
</div>WebSocket connections trigger htmx events (similar to SSE):
htmx:before:ws:connect- Before establishing connectionhtmx:after:ws:connect- After connection establishedhtmx:before:ws:send- Before sending messagehtmx:after:ws:send- After message senthtmx:before:ws:message- Before processing incoming messagehtmx:after:ws:message- After processing incoming messagehtmx:before:ws:reconnect- Before reconnection attempthtmx:ws:close- When connection closeshtmx:ws:error- On connection error
- Element with
hx-ws:connectis processed - Trigger fires (e.g.,
load,click) - WebSocket connection established
- Connection stored on
element._htmx.webSocket - Incoming messages processed as swaps
- Child elements with
hx-ws:sendfind parent connection - On disconnect: Automatic reconnection based on config
- On element removal: Connection closed
hx-ws:send elements search for the closest ancestor with hx-ws:connect:
let wsParent = element.closest('[hx-ws\\:connect]');
if (wsParent && wsParent._htmx.webSocket) {
// Send over this connection
}Multiple hx-ws:connect elements create independent connections:
<div hx-ws:connect="/chat" hx-trigger="load">
<form hx-ws:send>Chat message</form>
</div>
<div hx-ws:connect="/notifications" hx-trigger="load">
<form hx-ws:send>Different connection</form>
</div>Nested connections use the closest ancestor (like CSS specificity).
Client to Server:
- Form data is serialized as JSON object
hx-valsmerged into the data- Sent as text message over WebSocket
Server to Client:
- Plain HTML → swapped directly
<hx-partial>tags → out-of-band swaps- Server can send multiple partials in one message
| Parameter | Type | Default | Description |
|---|---|---|---|
autoConnect |
boolean | true |
Automatically add hx-trigger="load" to hx-ws:connect elements if no explicit trigger is specified |
reconnect |
boolean | true |
Enable automatic reconnection |
reconnectDelay |
number | 500 |
Initial reconnect delay in milliseconds |
reconnectMaxDelay |
number | 30000 |
Maximum backoff delay in milliseconds (30s) |
reconnectMaxAttempts |
number | Infinity |
Maximum number of reconnection attempts |
reconnectJitter |
number | 0 |
Jitter factor (0-1) to add randomness to backoff |
pauseInBackground |
boolean | false |
Pause connection when tab is hidden/backgrounded |
All parameters are optional - developers can:
Simple (95% of cases):
<div hx-ws:connect="/chat">Uses all defaults - connects automatically on load, reconnects forever with sensible backoff.
Common (adjust attempts):
<div hx-ws:connect="/chat"
hx-config='{"websockets": {"reconnectMaxAttempts": 5}}'>Advanced (fine-tune everything):
<div hx-ws:connect="/chat"
hx-config='{"websockets": {
"reconnect": true,
"reconnectDelay": 1000,
"reconnectMaxDelay": 60000,
"reconnectMaxAttempts": 10,
"reconnectJitter": 0.3,
"pauseInBackground": true
}}'>Explicit and Clear:
hx-ws:connect="/chat"- Obviously connects to WebSockethx-ws:send- Obviously sends over WebSocket- No ambiguity about what each does
Namespace Pattern:
- Similar to
hx-on:click,hx-on:load- uses:to specify the action hx-ws:is the WebSocket namespaceconnectandsendare the specific actions
Alternative considered: hx-ws="/url" was ambiguous - does it connect? send? unclear.
By default, autoConnect: true automatically adds hx-trigger="load" behavior to hx-ws:connect elements that don't have an explicit hx-trigger specified. This provides convenience for the common case (connecting on load) while allowing explicit override when needed.
Explicit triggers always take precedence:
<!-- Uses autoConnect: connects on load -->
<div hx-ws:connect="/chat">
<!-- Explicit trigger overrides autoConnect -->
<button hx-ws:connect="/chat" hx-trigger="click">
<button hx-ws:connect="/chat" hx-trigger="load delay:3s">This can be disabled globally by setting autoConnect: false in configuration.
WebSockets are inherently designed for persistent connections. If you don't want persistence, HTTP or SSE are better choices. This matches Socket.IO and other WebSocket libraries that reconnect by default.
Consistency and code reuse:
- Both protocols need identical reconnection logic (exponential backoff, max attempts, pause behavior)
- Same structure means familiar patterns for developers
- Allows internal code sharing for reconnection algorithms
<div hx-ext="ws" ws-connect="/chat">
<div id="notifications"></div>
<div id="chat_room">...</div>
<form ws-send>
<input name="message">
</form>
</div><div hx-ws:connect="/chat">
<div id="notifications"></div>
<div id="chat_room">...</div>
<form hx-ws:send hx-trigger="submit">
<input name="message">
</form>
</div>| Old (2.x) | New (4.x) | Notes |
|---|---|---|
hx-ext="ws" |
Not needed | WebSocket support is built-in |
ws-connect="/url" |
hx-ws:connect="/url" |
More explicit naming |
ws-send |
hx-ws:send |
Consistent hx- prefix |
| Connects immediately | autoConnect: true |
Connects on load by default, configurable |
hx-swap-oob |
<hx-partial> |
Use <hx-partial> tags for out-of-band swaps |
htmx.config.wsReconnectDelay |
htmx.config.websockets.reconnect* |
More granular reconnection config |
No implicit hx-swap-oob support:
The 2.x extension automatically processed elements with id attributes as out-of-band swaps. In 4.x, you must explicitly use <hx-partial> tags.
<!-- Old (2.x) - implicit OOB swap -->
<div id="notifications">New message</div>
<!-- New (4.x) - explicit with hx-partial -->
<hx-partial hx-target="#notifications" hx-swap="innerHTML">
<div>New message</div>
</hx-partial>Note: htmx 4.x will provide a global configuration option to enable implicit OOB behavior based on id attributes for backward compatibility if needed.
Old (2.x):
htmx.config.wsReconnectDelay = function(retryCount) {
return retryCount * 1000;
}New (4.x):
htmx.config.websockets = {
reconnect: true,
reconnectDelay: 500,
reconnectMaxDelay: 30000,
reconnectMaxAttempts: Infinity,
reconnectJitter: 0
}The new system uses exponential backoff with jitter by default, providing more robust reconnection behavior.
Currently focused on text/JSON. Binary WebSocket support could be added via events:
document.addEventListener('htmx:config:ws:send', (e) => {
// Intercept and send binary data
if (e.detail.binary) {
e.preventDefault();
e.detail.webSocket.send(binaryData);
}
});WebSocket-specific keepalive could be added to config:
websockets: {
pingInterval: 30000, // Send ping every 30s
pongTimeout: 5000 // Expect pong within 5s
}For non-HTML messages, events can be used:
document.addEventListener('htmx:before:ws:message', (e) => {
let msg = JSON.parse(e.detail.message);
if (msg.type === 'custom') {
// Handle custom message type
e.preventDefault(); // Don't swap
}
});