htmx and Django LiveView, side by side

Leer en español

If you use htmx and are considering migrating to Django LiveView, this article is for you. It's not an exhaustive comparison, but a run-through of the most common cases and how each technology solves them. Don't read it as a LiveView tutorial, since it leaves many features out.

I'll assume you know htmx and Django, and that you know how to create views, templates and routes. If not, I recommend reading the documentation for each one before continuing.

Fundamental differences

Before looking at code, let's be clear: architecturally they are very different. They don't even use the same communication protocol.

Feature htmx Django LiveView
Protocol HTTP/AJAX WebSockets
Communication Individual requests (GET, POST, etc.) Persistent connection
State Stateless Stateful
Real-time updates Partial, with polling Yes
Infrastructure requirements Minimal (HTTP server) Moderate (Channels + Redis recommended)
Latency Higher (each HTTP request) Lower (persistent connection)
Broadcast support No Yes
Requires an API Yes, views or REST API No

In practical terms, htmx is simpler to set up and use for basic interactions. However, when you want to tackle complex tasks, the complexity rises sharply. Django LiveView, on the other hand, needs more initial setup, but once it's running the curve is practically flat. The logic behind sending a message to every connected client is trivial: it looks a lot like opening a modal.

Case 1: Update content with a click

The most basic example. A button that updates a div.

htmx

<!-- Template -->
<div id="content">Initial content</div>
<button hx-get="/update-content" hx-target="#content">
    Update
</button>
# views.py
def update_content(request):
    return HttpResponse("<p>Updated content</p>")
# urls.py
path('update-content/', update_content),

Django LiveView

<!-- Base template -->
{% load static liveview %}
<!DOCTYPE html>
<html lang="en" data-room="{% liveview_room_uuid %}">
<head>
    <meta charset="UTF-8">
</head>
<body data-controller="page">
    <div id="content">{{ content }}</div>
    <button data-liveview-function="update_content"
            data-action="click->page#run">
        Update
    </button>
    <script src="{% static 'liveview/liveview.min.js' %}" defer></script>
</body>
</html>

The data-room attribute generates a unique identifier per page load, and data-controller="page" activates the controller that listens for events. Both go in the base HTML; the rest of your templates no longer repeat them.

# handlers.py
from liveview.decorators import liveview_handler
from liveview.connections import send

@liveview_handler("update_content")
def update_content(consumer, content):
    send(consumer, {
        "target": "#content",
        "html": "<p>Updated content</p>",
    })

Notice how htmx needs a specific route, while LiveView handles everything over the WebSocket with a decorator. The handler doesn't return an HTTP response: it calls send() to push the HTML to the client, and it can call it as many times as it wants.

Case 2: Form with validation

A form that validates on the server without reloading the page.

htmx

<!-- Template -->
<form hx-post="/validate-form" hx-target="#errors">
    <input type="email" name="email">
    <div id="errors"></div>
    <button type="submit">Submit</button>
</form>
# views.py
def validate_form(request):
    email = request.POST.get('email')
    if not email or '@' not in email:
        return HttpResponse('<p style="color:red;">Invalid email</p>')
    return HttpResponse('<p style="color:green;">Valid email</p>')
# urls.py
path('validate-form/', validate_form),

Django LiveView

<!-- Template -->
<form>
    <input type="email" name="email">
    <div id="errors">{{ error_message }}</div>
    <button type="submit"
            data-liveview-function="validate_form"
            data-action="click->page#run">Submit</button>
</form>
# handlers.py
from liveview.decorators import liveview_handler
from liveview.connections import send

@liveview_handler("validate_form")
def validate_form(consumer, content):
    email = content.get("form", {}).get("email", "")

    if not email or '@' not in email:
        error_html = '<p style="color:red;">Invalid email</p>'
    else:
        error_html = '<p style="color:green;">Valid email</p>'

    send(consumer, {
        "target": "#errors",
        "html": error_html,
    })

LiveView serializes the form closest to the button and hands it to you in content["form"], a dictionary keyed by each field's name. You don't need request.POST.

Search that updates as you type.

htmx

<!-- Template -->
<input type="text"
       name="query"
       hx-get="/search"
       hx-trigger="keyup changed delay:500ms"
       hx-target="#results">
<div id="results"></div>
# views.py
def search(request):
    query = request.GET.get('query', '')
    results = Article.objects.filter(title__icontains=query)[:5]
    return render(request, 'search_results.html', {'results': results})
# urls.py
path('search/', search),

The delay:500ms attribute prevents making a request on every keystroke.

Django LiveView

<!-- Template -->
<input type="text"
       name="query"
       data-liveview-function="search"
       data-action="input->page#run"
       data-liveview-debounce="500">
<div id="results">{% include 'search_results.html' %}</div>
# handlers.py
from django.template.loader import render_to_string
from liveview.decorators import liveview_handler
from liveview.connections import send
from .models import Article

@liveview_handler("search")
def search(consumer, content):
    query = content.get("form", {}).get("query", "")
    results = Article.objects.filter(title__icontains=query)[:5]

    html = render_to_string('search_results.html', {'results': results})

    send(consumer, {
        "target": "#results",
        "html": html,
    })

Here the trigger goes on the input itself (input->page#run). The data-liveview-debounce="500" attribute serves the same purpose as delay:500ms in htmx.

Case 4: Automatic update (polling)

Content that updates periodically.

htmx

<!-- Template -->
<div hx-get="/stats"
     hx-trigger="every 2s"
     id="stats">
    {{ stats }}
</div>
# views.py
def stats(request):
    active_users = get_active_users()
    return HttpResponse(f'<p>Active users: {active_users}</p>')
# urls.py
path('stats/', stats),

Django LiveView

Django LiveView has no automatic client-side polling. The philosophy is different: the server broadcasts when something changes. If you really want a periodic pulse, you run it as its own process, not inside the web server:

# management/commands/broadcast_stats.py
from time import sleep

from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.core.management.base import BaseCommand


class Command(BaseCommand):
    help = "Send the number of active users to every client every 2 seconds"

    def handle(self, *args, **options):
        channel_layer = get_channel_layer()
        while True:
            sleep(2)
            active_users = get_active_users()
            html = f"<p>Active users: {active_users}</p>"

            # 'broadcast' is the group LiveView joins every client to;
            # 'broadcast_message' is the consumer handler.
            async_to_sync(channel_layer.group_send)(
                "broadcast",
                {
                    "type": "broadcast_message",
                    "message": {"target": "#stats", "html": html},
                },
            )
<!-- Template -->
<div id="stats">{{ stats }}</div>

You start it as a standalone process (python manage.py broadcast_stats), managed by systemd or supervisor. Don't put it in a thread inside the ASGI server: with several workers you'd have one copy of the loop per worker, each broadcasting on its own. Even so, this approach is more powerful than polling: every client gets the update at the same time, without each one asking on its own.

Case 5: SPA navigation

Navigate without reloading the whole page.

htmx

<!-- Base template -->
<nav hx-boost:inherited="true">
    <a href="/about">About us</a>
    <a href="/contact">Contact</a>
</nav>

<div id="content">
    <!-- Content -->
</div>
# views.py
def about(request):
    return render(request, 'about.html')

def contact(request):
    return render(request, 'contact.html')
# urls.py
path('about/', about),
path('contact/', contact),

hx-boost turns normal links into AJAX requests. htmx intercepts the click, makes a GET request and replaces the <body> with the response content. Notice the :inherited suffix: in htmx 4 it's required for the <nav> attribute to apply to the child links. In htmx 2, hx-boost="true" was enough, because inheritance was automatic.

Django LiveView

<!-- Template -->
<nav>
    <a href="#"
       data-liveview-function="load_about"
       data-action="click->page#run">
        About us
    </a>
    <a href="#"
       data-liveview-function="load_contact"
       data-action="click->page#run">
        Contact
    </a>
</nav>

<div id="content">
    <!-- Content -->
</div>
# handlers.py
from django.template.loader import render_to_string
from liveview.decorators import liveview_handler
from liveview.connections import send

@liveview_handler("load_about")
def load_about(consumer, content):
    html = render_to_string('about.html')
    send(consumer, {
        "target": "#content",
        "html": html,
    })

@liveview_handler("load_contact")
def load_contact(consumer, content):
    html = render_to_string('contact.html')
    send(consumer, {
        "target": "#content",
        "html": html,
    })

Case 6: Shared state between users

Multiple users viewing data in real time.

htmx

No support in the core. You have to fall back on Server-Sent Events (SSE) or WebSockets through extensions (htmx 4 adds the hx-live extension for this), which steps outside htmx's request/response model.

Django LiveView

# handlers.py
from liveview.decorators import liveview_handler
from liveview.connections import send

@liveview_handler("add_message")
def add_message(consumer, content):
    message_text = content.get("form", {}).get("message", "")

    # broadcast=True sends the message to every connected client
    send(
        consumer,
        {
            "target": "#messages",
            "html": f'<p>{message_text}</p>',
            "append": True,
        },
        broadcast=True,
    )
<!-- Template -->
<div id="messages">
    <!-- Messages appear here -->
</div>
<form>
    <input type="text" name="message">
    <button type="submit"
            data-liveview-function="add_message"
            data-action="click->page#run">Send</button>
</form>

All connected users receive the message instantly.

Case 7: File handling

htmx

<form hx-post="/upload"
      hx-encoding="multipart/form-data"
      hx-target="#result">
    <input type="file" name="file">
    <button type="submit">Upload</button>
</form>
<div id="result"></div>
# views.py
def upload(request):
    if request.method == 'POST':
        uploaded_file = request.FILES['file']
        # Process file
        return HttpResponse(f'<p>File {uploaded_file.name} uploaded</p>')
# urls.py
path('upload/', upload),

Django LiveView

<!-- Template -->
<form enctype="multipart/form-data">
    <input type="file" name="file">
    <button type="submit"
            data-liveview-function="upload_file"
            data-action="click->page#run">Upload</button>
</form>
<div id="result"></div>
# handlers.py
from liveview.decorators import liveview_handler
from liveview.connections import send

@liveview_handler("upload_file")
def upload_file(consumer, content):
    uploaded_file = content.get("form", {}).get("file")
    if uploaded_file:
        # Process file
        send(consumer, {
            "target": "#result",
            "html": f'<p>File {uploaded_file} uploaded</p>',
        })

Here htmx has the edge: it uploads the file over HTTP with multipart/form-data with no tricks. Over a WebSocket, the file travels inside the JSON message, so for large files htmx's HTTP request is still the more comfortable option.

htmx already has WebSockets

Both can speak over WebSocket, but it doesn't mean the same thing when they do.

In htmx, HTTP is the default transport and the WebSocket is an extension you enable on a specific element (in htmx 4, the hx-live extension). You connect that part of the page to a socket, and the incoming messages carry HTML that htmx inserts. The rest of the application stays on HTTP. On top of that, you write the server consumer yourself: you define the message format, the rooms and the broadcasting.

In Django LiveView, the WebSocket is the transport for the whole page. Every interaction travels over it, not just the real-time bits. The framework manages the connection, the rooms, the broadcast and the reconnection; you only write handlers and call send(). That's why broadcasting to everyone is a single line (send(..., broadcast=True)) instead of wiring up a consumer by hand.

Feature htmx + WebSocket extension Django LiveView
Automatic connection and reconnection
Send events and update the DOM with HTML
Per-connection state on the server
Broadcast to all clients 🟡 you build it broadcast=True
Rooms or client groups 🟡 you build it
History and URL with snapshots and scroll restoration ❌ (htmx's history is from its HTTP mode)
Event debounce data-liveview-debounce
Visibility triggers (scroll or intersection) data-liveview-intersect
Keyboard shortcut maps 🟡 key filters data-liveview-keyboard-map
Focus management after a DOM update 🟡 hx-preserve data-liveview-focus

In short, in htmx the WebSocket is an add-on for specific parts of an app that stays on HTTP; in LiveView it's the backbone.

Final notes

You can keep SSR for some pages, htmx in some components and LiveView in others. One technology doesn't rule out the other. Both are complementary and aim for maximum simplicity within their paradigms.

That said, they're not interchangeable: you can't use htmx to update a component managed by LiveView, and vice versa. In the end, it's you who has to decide which one fits each case best. But whichever you pick, let it be because you have experience in different paradigms and not because you don't know the other option.

Happy Hacking!

Help me keep writing

Every coffee gives me a push toward the next article.

Comments

There are no comments yet.

You may also like