2025, Oct 21 22:00

HTMX over WebSocket in Django Channels: Message Arrives, DOM Stays Empty - Fix the Target Wrapper or Use hx-swap-oob

Troubleshooting HTMX over WebSocket in Django Channels: learn why swaps render nothing when the target wrapper is missing and how hx-swap-oob or wrapping fixes.

HTMX over WebSocket sometimes “works but renders nothing”: you see the server message arrive in DevTools, yet the target area stays blank. This guide dissects a concrete case with Django Channels and shows why the DOM remains empty and how to make the swap actually happen.

Problem overview

A WebSocket consumer sends a rendered HTML fragment on connect. The page uses the HTMX WebSocket extension to receive that fragment and swap it into a target container. The message lands, but the dashboard stays empty.

Repro code

Server-side consumer that renders counts and pushes them to the client:

from channels.generic.websocket import WebsocketConsumer
from django.template.loader import render_to_string
from myapp.models import Model1, Model2, Model3, Model4
class MetricsBannerSocket(WebsocketConsumer):
    def push_totals(self):
        figures = [
            {"label": "Model1", "total": Model1.objects.count()},
            {"label": "Model2", "total": Model2.objects.count()},
            {"label": "Model3", "total": Model3.objects.count()},
            {"label": "Model4", "total": Model4.objects.count()},
        ]
        html_payload = render_to_string("dashboard/metrics-fragment.html", {"figures": figures})
        self.send(text_data=html_payload)
    def connect(self):
        self.accept()
        self.push_totals()

HTML fragment used for rendering the stats:

{% for row in figures %}
<div class="col-sm-6 col-xl-3">
    <div class="dashboard-stat rounded d-flex align-items-center justify-content-between p-4">
        <div class="ms-3">
            <p class="mb-2">{{ row.label }}</p>
            <h6 class="mb-0">{{ row.total }}</h6>
        </div>
    </div>
</div>
{% endfor %}

Client-side template that hosts the WebSocket connection and declares swap behavior:

{% load static %}
{% block styles %}
    <link rel="stylesheet" href="{% static 'css/dashboard.css' %}">
{% endblock %}
<div class="container-fluid pt-4 px-4">
    <div class="row g-2 mb-2 stats-wrapper"
         hx-ext="ws"
         ws-connect="/ws/dashboard/header/"
         hx-target=".stats-wrapper"
         hx-swap="innerHTML"
    >
    </div>
    <div class="active-tasks-scroll-container">
        <div class="row flex-nowrap g-2">
        </div>
    </div>
</div>

Root cause

The server is sending raw HTML that does not include the .stats-wrapper element. The swap target is looking for .stats-wrapper, but the response fragment lacks it, so HTMX has no element to match and nothing gets replaced in the DOM. You can see the message in the network panel, yet no render occurs because the response doesn’t carry the target container that HTMX expects to operate on.

How to fix it

There are two straightforward ways to make the swap work reliably with the WebSocket payload.

The first option is to include the wrapper in the response payload so HTMX can locate .stats-wrapper and perform the replacement. Here is the adjusted template:

<div class="row g-2 mb-2 stats-wrapper">
    {% for row in figures %}
    <div class="col-sm-6 col-xl-3">
        <div class="dashboard-stat rounded d-flex align-items-center justify-content-between p-4">
            <div class="ms-3">
                <p class="mb-2">{{ row.label }}</p>
                <h6 class="mb-0">{{ row.total }}</h6>
            </div>
        </div>
    </div>
    {% endfor %}
</div>

The second option is to keep sending only the inner content and make the response out-of-band, so HTMX will find and replace the existing .stats-wrapper in the DOM even when it’s not the direct swap target of the current element.

<div class="row g-2 mb-2 stats-wrapper" hx-swap-oob="true">
    {% for row in figures %}
    <div class="col-sm-6 col-xl-3">
        <div class="dashboard-stat rounded d-flex align-items-center justify-content-between p-4">
            <div class="ms-3">
                <p class="mb-2">{{ row.label }}</p>
                <h6 class="mb-0">{{ row.total }}</h6>
            </div>
        </div>
    </div>
    {% endfor %}
</div>

With either approach, HTMX can locate the matching .stats-wrapper element and perform the swap so the counts appear as intended.

Why this matters

When streaming server-rendered fragments into the browser, the swap target that HTMX uses must be discoverable in the payload or marked for out-of-band replacement. If the incoming HTML doesn’t carry the element HTMX intends to replace, the update silently fails from the user’s perspective. Ensuring the target exists either in the response or via out-of-band semantics keeps WebSocket-driven UIs predictable and debuggable.

Conclusion

If a WebSocket response renders nothing despite arriving successfully, verify that the fragment includes the element HTMX is supposed to swap or use hx-swap-oob to direct the replacement. That small structural detail is often the difference between an empty dashboard and a live, updating header.

The article is based on a question from StackOverflow by user31153869 and an answer by Mahrez BenHamad.