From htmx to Django LiveView

If you're using htmx and evaluating Django LiveView, this article is for you. I want to quickly show you the equivalences of what you usually do with htmx and how to achieve it with Django LiveView. It's not an exhaustive comparison, but a practical guide to migrate or decide which one to use.

Fundamental differences

Before looking at code, we need to be clear: architecturally they are very different. They don't even share 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 No (polling required) Yes (server push)
Infrastructure requirements Minimal (HTTP server) Moderate (Channels + Redis recommended)
Latency Higher (each HTTP request) Lower (persistent connection)
Broadcast support No Yes
Performance* Good (16.48ms) Excellent (9.35ms)
Requires Views or REST API Django Channels

In practical terms, htmx is simpler to configure and use for basic interactions. Django LiveView is more powerful for real-time interactive applications, when you need to maintain server state, broadcast to multiple clients, or create SPA-type applications.

Installation and configuration

htmx

<!-- In your base template -->
<script src="https://unpkg.com/htmx.org@2.0.8"></script>

Done. No server configuration.

Optionally, you can install django-htmx to facilitate working with htmx in Django. It provides middleware that detects htmx requests and simplifies header handling:

pip install django-htmx
# settings.py
MIDDLEWARE = [
    # ... other middleware
    'django_htmx.middleware.HtmxMiddleware',
]

With this, you can use request.htmx in your views to detect htmx requests and access specific headers.

Django LiveView

Requires Django Channels and an ASGI server like Daphne. Redis is not mandatory, but is highly recommended as otherwise you will lose some communication functionalities.

Basic installation (without Redis, testing only):

pip install django-liveview channels daphne

Recommended installation (with Redis, for development and production):

pip install django-liveview channels channels-redis daphne redis
# settings.py
INSTALLED_APPS = [
    'daphne',  # Must be first
    'django.contrib.staticfiles',
    # ... other apps
    'liveview',
]

ASGI_APPLICATION = 'myproject.asgi.application'

# Option 1: Without Redis (local testing only)
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels.layers.InMemoryChannelLayer',
    },
}

# Option 2: With Redis (recommended)
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            'hosts': [('127.0.0.1', 6379)],
        },
    },
}
# asgi.py
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from liveview import routing

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')

application = ProtocolTypeRouter({
    'http': get_asgi_application(),
    'websocket': AuthMiddlewareStack(
        URLRouter(routing.websocket_urlpatterns)
    ),
})
# urls.py
from django.urls import path, include

urlpatterns = [
    path('', include('liveview.urls')),
    # ... your urls
]

You need more packages and configurations. But as you'll see, the power you get in return is worth it.

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

<!-- Template -->
{% load liveview %}
<!DOCTYPE html>
<html data-liveview-room-uuid="{% liveview_room_uuid %}">
<head>
    {% liveview_headers %}
</head>
<body>
    <div id="content">{{ content }}</div>
    <button data-liveview-function="update_content"
            data-action="click->page#run">
        Update
    </button>
</body>
</html>
# handlers.py
from liveview import liveview_handler

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

Notice how htmx needs a specific route, while LiveView handles everything via WebSocket with a decorator.

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 -->
{% load liveview %}
<form data-liveview-function="validate_form"
      data-action="submit->page#run">
    <input type="email" name="email" id="email-input">
    <div id="errors">{{ error_message }}</div>
    <button type="submit">Submit</button>
</form>
# handlers.py
from liveview import liveview_handler

@liveview_handler("validate_form")
def validate_form(consumer, content):
    email = content.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>'

    return {
        "target": "#errors",
        "html": error_html,
    }

With Django LiveView you can access the form state directly from the content object without having to use 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 requests on every key press.

Django LiveView

<!-- Template -->
{% load liveview %}
<input type="text"
       name="query"
       id="search-input"
       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 import liveview_handler
from .models import Article

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

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

    return {
        "target": "#results",
        "html": html,
    }

The data-liveview-debounce="500" attribute serves the same function 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 doesn't have automatic client-side polling. The philosophy is different: the server broadcasts when there are changes. But you can simulate it with a thread on the server:

# handlers.py
import threading
from time import sleep
from liveview import liveview_handler
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync

def poll_stats():
    """Thread that updates stats every 2 seconds"""
    channel_layer = get_channel_layer()
    while True:
        sleep(2)
        active_users = get_active_users()
        html = f'<p>Active users: {active_users}</p>'

        async_to_sync(channel_layer.group_send)(
            'broadcast',
            {
                'type': 'broadcast_message',
                'message': {
                    'target': '#stats',
                    'html': html,
                }
            }
        )

# Start thread when app starts
threading.Thread(target=poll_stats, daemon=True).start()
<!-- Template -->
<div id="stats">{{ stats }}</div>

This approach is more powerful because all connected clients receive the update simultaneously, without each one doing individual polling.

Case 5: SPA navigation

Navigate without reloading the entire page.

htmx

<!-- Base template -->
<nav hx-boost="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="true" converts normal links into AJAX requests. htmx intercepts the click, makes a GET request and replaces the <body> with the response content.

Django LiveView

<!-- Template -->
{% load liveview %}
<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 import liveview_handler

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

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

Case 6: Shared state between users

Multiple users viewing real-time data.

htmx

No native support. You need to implement Server-Sent Events (SSE) or WebSockets manually, which breaks the htmx model.

Django LiveView

# handlers.py
from liveview import liveview_handler
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync

@liveview_handler("add_message")
def add_message(consumer, content):
    message_text = content.get('message', '')
    channel_layer = get_channel_layer()

    # Broadcast to all connected users
    async_to_sync(channel_layer.group_send)(
        'chat_room',
        {
            'type': 'broadcast_message',
            'message': {
                'target': '#messages',
                'html': f'<p>{message_text}</p>',
            }
        }
    )
<!-- Template -->
<div id="messages">
    <!-- Messages appear here -->
</div>
<form data-liveview-function="add_message"
      data-action="submit->page#run">
    <input type="text" name="message">
    <button type="submit">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

{% load liveview %}
<form data-liveview-function="upload_file"
      data-action="submit->page#run"
      enctype="multipart/form-data">
    <input type="file" name="file" id="file-input">
    <button type="submit">Upload</button>
</form>
<div id="result"></div>
# handlers.py
from liveview import liveview_handler

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

Functionally similar, but over WebSocket.

Practical migration

If you decide to migrate from htmx to LiveView:

  1. Install basic dependencies: django-liveview, channels, daphne (and optionally channels-redis + Redis for production)
  2. Configure ASGI: Modify settings.py and asgi.py according to the installation section
  3. Configure channel layers: Use InMemoryChannelLayer for testing or RedisChannelLayer for production
  4. Convert views to handlers: Each view that returns partial HTML becomes a handler with @liveview_handler
  5. Update templates: Replace hx-* attributes with data-liveview-* and data-action. Also add the necessary tags for LiveView

You can keep SSR for some pages, htmx in some components and use LiveView in others. One technology doesn't exclude the other.

There is no wrong choice. Both technologies are complementary and seek maximum simplicity within their paradigms. Choose the one that best suits your needs and scale from there. Happy Hacking!

This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.

Will you buy me a coffee?

Comments

There are no comments yet.

Written by Andros Fenollosa

February 3, 2026

8 min of reading

You may also like

Visitors in real time

You are alone: 🐱