htmx 4.0 is under construction — migration guide

WebSockets

Enable bidirectional real-time communication via WebSockets

The WebSocket extension enables real-time, bidirectional communication with WebSocket servers directly from HTML. It manages connections with automatic reconnection, connection sharing, and seamless integration with htmx’s swap and event model.

Installing

<script src="https://cdn.jsdelivr.net/npm/htmx.org@next/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@next/dist/ext/hx-ws.js"></script>

Usage

Use these attributes to configure WebSocket behavior:

AttributeDescription
hx-ws:connect="<url>"Establishes a WebSocket connection to the specified URL
hx-ws:sendSends form data or hx-vals to the WebSocket on trigger
hx-ws:send="<url>"Like hx-ws:send but creates its own connection to the URL

JSX-Compatible Variants: For frameworks that don’t support colons in attribute names, use hyphen variants: hx-ws-connect and hx-ws-send.

Basic Example

<div hx-ws:connect="/chatroom" hx-target="#messages" hx-swap="beforeend"> <div id="messages"></div> <form hx-ws:send> <input name="message" placeholder="Type a message..."> <button type="submit">Send</button> </form> </div>

This example:

  1. Establishes a WebSocket connection to /chatroom when the page loads
  2. Appends incoming HTML messages to #messages
  3. Sends form data as JSON when the form is submitted

URL Normalization

WebSocket URLs are automatically normalized:

InputOutput (on HTTPS page)
/ws/chatwss://example.com/ws/chat
ws://localhost:8080/wsws://localhost:8080/ws
https://api.example.com/wswss://api.example.com/ws
//cdn.example.com/wswss://cdn.example.com/ws

This means you can use simple relative paths in most cases, and the extension will construct the correct WebSocket URL.

Receiving Messages

JSON Message Format

Messages from the server should be JSON objects with a content field:

{ "content": "<div class='notification'>New message!</div>", "target": "#notifications", "swap": "beforeend", "HX-Request-ID": "abc-123" }
FieldDefaultDescription
contentHTML content to swap into the target
targetElement’s hx-targetCSS selector for target element
swapElement’s hx-swapSwap strategy (innerHTML, beforeend, etc.)
HX-Request-IDMatches response to original request

Minimal example:

{"content": "<div>Hello World</div>"}

Data-Only Messages

JSON messages without a content field are not swapped. Use htmx:before:ws:message to handle them:

document.addEventListener('htmx:before:ws:message', (e) => { if (e.detail.message.json?.type === 'notification') { showNotification(e.detail.message.json.text); } });

Raw HTML Messages

If the server sends a plain string (not JSON), it’s treated as raw HTML:

<div class="alert">Server restarting in 5 minutes</div>

If the connection element has an hx-target, the HTML is swapped into that target. Without an hx-target, swap:none is used - but <hx-partial> elements in the message can still target their own destinations:

<hx-partial hx-target="#alerts" hx-swap="beforeend"><div class="alert">New alert</div></hx-partial>

Request-Response Matching

Each sent message includes a unique HX-Request-ID header. When the server echoes it back in the response, the content is routed to the element that sent the request (not the connection element):

<form hx-ws:send hx-target="#result"> <input name="query" value="hello"> <button>Search</button> </form> <div id="result"></div>

The form sends {"headers": {"HX-Request-ID": "abc-123", ...}, "body": {"query": "hello"}}. The server responds with:

{"content": "<p>Results for 'hello'</p>", "HX-Request-ID": "abc-123"}

Because HX-Request-ID matches, the content is swapped into #result (the form’s hx-target), not the connection element.

Sending Messages

When an element with hx-ws:send is triggered, the extension sends a structured JSON message with separate headers and body:

{ "headers": { "HX-Request": "true", "HX-Request-ID": "550e8400-e29b-41d4-a716-446655440000", "HX-Source": "form#chat-form", "HX-Target": "div#messages", "HX-Current-URL": "https://example.com/chat" }, "body": { "message": "Hello!", "tags": ["urgent", "public"] } }

Headers (from htmx core, same as HTTP requests):

HeaderDescription
HX-RequestAlways "true"
HX-Request-IDUnique ID for request/response matching
HX-SourceSource element identifier (tag#id format)
HX-TargetTarget element identifier (only if hx-target is set)
HX-Current-URLCurrent page URL

Body contains form data and hx-vals. Multi-value fields (like checkboxes or multi-selects) are preserved as arrays.

Forms

<form hx-ws:send> <input name="username"> <input name="message"> <button type="submit">Send</button> </form>

Buttons with hx-vals

<button hx-ws:send hx-vals='{"action": "increment"}'>+1</button>

Modifying Messages Before Send

Use the htmx:before:ws:request event to modify or cancel messages:

document.addEventListener('htmx:before:ws:request', (e) => { e.detail.headers['Authorization'] = 'Bearer ' + getToken(); // Or cancel the send if (!isValid(e.detail.body)) { e.preventDefault(); } });

Configuration

Configure the extension globally via htmx.config.ws or per-element via hx-config:

htmx.config.ws = { reconnect: true, // Enable auto-reconnect (default: true) reconnectDelay: 500, // Initial reconnect delay in ms (default: 500) reconnectMaxDelay: 60000, // Maximum reconnect delay in ms (default: 60000) reconnectMaxAttempts: Infinity,// Maximum reconnect attempts (default: Infinity) reconnectJitter: 0.3, // Jitter factor 0-1 for randomizing delays (default: 0.3) pauseOnBackground: true, // Pause connection when tab is backgrounded (default: true) pendingRequestTTL: 30000 // TTL for pending requests in ms (default: 30000) };
<!-- Per-element override --> <div hx-ws:connect="/ws" hx-config="ws.reconnectDelay:1s ws.reconnectMaxAttempts:5">

Delay values accept time strings: "500ms", "1s", "2m", or raw milliseconds.

Per-element config is read from the element that creates the connection and stored on the connection object for its lifetime.

Reconnection Strategy

The extension uses exponential backoff with optional jitter:

  • Base formula: delay = min(reconnectDelay × 2^(attempts-1), reconnectMaxDelay)
  • Jitter: Adds ±30% randomization to avoid thundering herd
  • Reset: Attempts counter resets to 0 on successful connection

Example reconnection delays with defaults:

  • Attempt 1: ~500ms
  • Attempt 2: ~1000ms
  • Attempt 3: ~2000ms
  • Attempt 4: ~4000ms
  • Attempt 5+: ~60000ms (capped)

Connection Triggers

By default, connections are established immediately when the element is processed:

<!-- Connects immediately when element appears (default) --> <div hx-ws:connect="/ws"> <!-- Explicit load trigger - same behavior as no trigger --> <div hx-ws:connect="/ws" hx-trigger="load"> <!-- Deferred connection - only connects when button is clicked --> <div hx-ws:connect="/ws" hx-trigger="click from:#connect-btn">

Use hx-trigger when you want to delay connection establishment (e.g., wait for user action).

All standard hx-trigger modifiers are supported (delay, throttle, once, etc.).

Events

Connection Lifecycle

EventCancelableDetailDescription
htmx:before:ws:connection{connection}Before connection attempt (initial or reconnect)
htmx:after:ws:connection{connection}After successful connection
htmx:ws:close{connection, reason, code}When connection closes (see below)
htmx:ws:error{url, error}On connection or send error

The htmx:ws:close detail includes:

  • connection - the connection object
  • reason - why the connection closed: "closed" (WebSocket close event), "removed" (element removed from DOM), or "cancelled" (connection cancelled via htmx:before:ws:connection event)
  • code - the WebSocket close code (e.g. 1000), or null when removed via DOM cleanup

The connection detail is the actual internal connection object, which includes:

  • attempt: 0 for initial connection, > 0 for reconnections
  • url: the WebSocket URL
  • config: the resolved configuration for this connection
  • socket: the WebSocket instance (available on after events; null before initial connection)
  • cancelled: set to true in before events to cancel the connection
  • pendingRequests: Map of in-flight request/response pairs

Request Events

EventCancelableDetailDescription
htmx:before:ws:request{headers, body}Before sending (headers and body are modifiable)
htmx:after:ws:request{headers, body}After message sent

Message Events

EventCancelableDetailDescription
htmx:before:ws:message{message: {text, json, cancelled}}Before processing any received message
htmx:after:ws:message{message: {text, json}}After processing any received message

message.text is the raw string. message.json is the parsed JSON object (or null for non-JSON). Set message.cancelled = true in the before event to prevent processing.

Event Examples

Cancel Connection Based on Condition:

document.addEventListener('htmx:before:ws:connection', (e) => { if (e.detail.connection.attempt > 10) { e.detail.connection.cancelled = true; // Stop reconnecting } });

Handle Data-Only Messages:

document.addEventListener('htmx:before:ws:message', (e) => { if (e.detail.message.json?.type === 'audio') { playAudioNotification(e.detail.message.json.url); } });

Log All WebSocket Activity:

document.addEventListener('htmx:after:ws:connection', (e) => { console.log('Connected to', e.detail.connection.url); }); document.addEventListener('htmx:ws:close', (e) => { console.log('Disconnected from', e.detail.connection.url, 'reason:', e.detail.reason); });

Connection Management

Connection Sharing

Multiple elements pointing to the same WebSocket URL share a single connection:

<div hx-ws:connect="/notifications" id="notif-1"> <!-- Uses connection to /notifications --> </div> <div hx-ws:connect="/notifications" id="notif-2"> <!-- Shares the same connection --> </div>

When all elements using a connection are removed from the DOM, the connection is automatically closed.

Element Cleanup

When elements are removed (e.g., via htmx swap), the extension checks if any other element in the DOM still references the same WebSocket URL. If not, the connection is closed.

This happens automatically through htmx’s element cleanup lifecycle.

HTML Swapping

When a JSON message with a content field arrives, the extension uses htmx’s swap API, which provides:

  • All swap styles (innerHTML, outerHTML, beforebegin, afterend, beforeend, afterbegin, delete, none)
  • Preserved elements (hx-preserve)
  • Auto-focus handling
  • Scroll handling
  • Proper cleanup of removed elements
  • htmx.process() called on newly inserted content

Target Resolution

Target is determined in this order:

  1. target field in the message
  2. hx-target attribute on the element that sent the request (if HX-Request-ID matches)
  3. hx-target attribute on the connection element
  4. The connection element itself

Swap Strategy

Swap strategy is determined in this order:

  1. swap field in the message
  2. hx-swap attribute on the connection element (or the element that sent the request)
  3. htmx.config.defaultSwap (default: innerHTML)

Examples

Live Chat

<div hx-ws:connect="/chat"> <div id="messages" hx-target="this" hx-swap="beforeend"></div> <form hx-ws:send> <input name="message" placeholder="Message..." autocomplete="off"> <button type="submit">Send</button> </form> </div>

Server sends:

{"content": "<div class='message'><b>User:</b> Hello!</div>"}

Real-Time Notifications

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

Interactive Counter

<div hx-ws:connect="/counter"> <div id="count" hx-target="this">0</div> <button hx-ws:send hx-vals='{"action":"increment"}'>+</button> <button hx-ws:send hx-vals='{"action":"decrement"}'>-</button> </div>

Multiple Widgets Sharing Connection

<div hx-ws:connect="/dashboard"> <div id="cpu-usage">--</div> <div id="memory-usage">--</div> <div id="disk-usage">--</div> </div>

Server sends targeted updates:

{"content": "<span>45%</span>", "target": "#cpu-usage"} {"content": "<span>2.3 GB</span>", "target": "#memory-usage"}

Upgrading from htmx 2.x

Attribute Changes

Old (htmx 2.x)New (htmx 4.x)Notes
ws-connect="<url>"hx-ws:connect="<url>"Or hx-ws-connect for JSX
ws-sendhx-ws:sendOr hx-ws-send for JSX

The old ws-connect and ws-send attributes still work but emit a deprecation warning.

Event Changes

Old EventNew EventNotes
htmx:wsConnecting(none)Removed
htmx:wsOpenhtmx:after:ws:connectionDifferent detail structure
htmx:wsClosehtmx:ws:closeNow includes code and reason
htmx:wsErrorhtmx:ws:errorSimilar
htmx:wsBeforeMessagehtmx:before:ws:messageDifferent detail structure
htmx:wsAfterMessagehtmx:after:ws:messageDifferent detail structure
htmx:wsConfigSendhtmx:before:ws:requestModify e.detail.headers and e.detail.body
htmx:wsBeforeSendhtmx:before:ws:requestCombined into one event
htmx:wsAfterSendhtmx:after:ws:requestSimilar

Configuration Changes

OldNew
htmx.config.wsReconnectDelayhtmx.config.ws.reconnectDelay
createWebSocket optionNot supported (use events)
wsBinaryType optionNot supported

Note: earlier htmx 4.0 alpha builds used htmx.config.websockets. This has been renamed to htmx.config.ws for consistency with the extension name and attribute prefix.

Message Format Changes

Send payload is now a structured {headers, body} JSON object. Headers contain HX-Request, HX-Request-ID, HX-Source, HX-Target, HX-Current-URL. Body contains form data and hx-vals.

Receive format now expects JSON with content, target, swap fields instead of raw HTML or hx-swap-oob. Messages with a content field are auto-swapped; messages without content are data-only (handle via events). Use HX-Request-ID (instead of the old request_id) for request-response matching.

Socket Wrapper Removed

The socketWrapper object is no longer exposed in events. Use the standard WebSocket events and the extension’s event system instead.