Skip to content

Instantly share code, notes, and snippets.

@scriptogre
Last active November 11, 2025 20:55
Show Gist options
  • Select an option

  • Save scriptogre/1389dcc4f35b610b9bbd2f1ffa1bdef4 to your computer and use it in GitHub Desktop.

Select an option

Save scriptogre/1389dcc4f35b610b9bbd2f1ffa1bdef4 to your computer and use it in GitHub Desktop.
htmx 4.0 WebSocket Extension Design

WebSockets Extension Design

Overview

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.

Core Principles

  1. Consistency with SSE - Incoming messages use the same swapping behavior (hx-target, hx-swap, <hx-partial>)
  2. Leverage existing attributes - Reuse hx-vals, hx-include, hx-trigger, hx-config
  3. Clear semantics - WebSocket operations distinct from HTTP
  4. Progressive complexity - Simple cases work with minimal config, advanced features available when needed

Attributes

hx-ws:connect="/url"

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)

hx-ws:send

Sends data over the WebSocket connection.

Behavior:

  • Finds closest ancestor with hx-ws:connect attribute
  • 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>

Receiving Messages

Incoming WebSocket messages are treated as responses and follow the same swapping rules as SSE:

Direct Swap

<!-- Server sends HTML -->
<div>New message content</div>

<!-- Swaps into target using hx-swap strategy -->

Out-of-Band Swaps with <hx-partial>

<!-- 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.

Configuration

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
}

Per-Element Configuration

<div hx-ws:connect="/chat"
     hx-trigger="load"
     hx-config='{"websockets": {
       "reconnect": true,
       "reconnectMaxAttempts": 5,
       "reconnectDelay": 1000,
       "pauseInBackground": true
     }}'>
</div>

Global Configuration

<meta name="htmx:config" content='{
  "websockets": {
    "autoConnect": true,
    "reconnect": true,
    "reconnectMaxAttempts": 10,
    "reconnectDelay": 1000,
    "reconnectMaxDelay": 60000,
    "reconnectJitter": 0.2,
    "pauseInBackground": false
  }
}'>

Defaults vs. SSE

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

Reconnection Behavior

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.

Complete Examples

Chat Application

<!-- 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!"}

Live Dashboard

<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>

Conditional Connection

<!-- 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.

Sending Complex Data

<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>

Events

WebSocket connections trigger htmx events (similar to SSE):

  • htmx:before:ws:connect - Before establishing connection
  • htmx:after:ws:connect - After connection established
  • htmx:before:ws:send - Before sending message
  • htmx:after:ws:send - After message sent
  • htmx:before:ws:message - Before processing incoming message
  • htmx:after:ws:message - After processing incoming message
  • htmx:before:ws:reconnect - Before reconnection attempt
  • htmx:ws:close - When connection closes
  • htmx:ws:error - On connection error

Implementation Notes

Connection Lifecycle

  1. Element with hx-ws:connect is processed
  2. Trigger fires (e.g., load, click)
  3. WebSocket connection established
  4. Connection stored on element._htmx.webSocket
  5. Incoming messages processed as swaps
  6. Child elements with hx-ws:send find parent connection
  7. On disconnect: Automatic reconnection based on config
  8. On element removal: Connection closed

Finding Parent Connection

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 Connections

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).

Message Format

Client to Server:

  • Form data is serialized as JSON object
  • hx-vals merged 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

Configuration Parameters Reference

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

Sharp Tools Philosophy

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
     }}'>

Design Rationale

Why hx-ws:connect and hx-ws:send?

Explicit and Clear:

  • hx-ws:connect="/chat" - Obviously connects to WebSocket
  • hx-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 namespace
  • connect and send are the specific actions

Alternative considered: hx-ws="/url" was ambiguous - does it connect? send? unclear.

The autoConnect Setting

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.

Why default reconnect: true for WebSockets?

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.

Why the same config structure as streams?

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

Migration from htmx 2.x WebSocket Extension

Old Syntax (htmx 2.x)

<div hx-ext="ws" ws-connect="/chat">
  <div id="notifications"></div>
  <div id="chat_room">...</div>
  <form ws-send>
    <input name="message">
  </form>
</div>

New Syntax (htmx 4.x)

<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>

Key Differences

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

Breaking Changes

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.

Reconnection Configuration

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.

Future Considerations

Binary Data

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);
  }
});

Ping/Pong Keepalive

WebSocket-specific keepalive could be added to config:

websockets: {
  pingInterval: 30000,  // Send ping every 30s
  pongTimeout: 5000     // Expect pong within 5s
}

Custom Message Handlers

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
  }
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment