Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

4 min read

Streaming & Real-Time Partial Updates

djust's streaming system enables token-by-token updates for LLM chat responses, live feeds, and any use case that requires sending incremental DOM changes without full re-renders.

What You Get

  • StreamingMixin -- Server-side mixin with stream_to(), stream_insert(), stream_text(), and more
  • Stream operations -- append, prepend, replace, text, delete, error, start, done
  • dj-stream directive -- Mark DOM elements as stream targets
  • Auto-batching -- Rapid updates are batched to ~60fps to avoid flooding the WebSocket
  • Auto-scroll -- Stream containers scroll to bottom when the user is near the end
  • Error recovery -- Errors display inline without losing partial content

Quick Start

1. Subclass LiveView (streaming is built in)

StreamingMixin is already included in LiveView, so the stream_* methods are always available — just subclass LiveView. Do not also inherit StreamingMixin (e.g. class ChatView(StreamingMixin, LiveView)); because it is already in LiveView's bases, that raises TypeError: Cannot create a consistent method resolution.

from djust import LiveView
from djust.decorators import event_handler

class ChatView(LiveView):
    template_name = 'chat.html'

    async def mount(self, request, **kwargs):
        self.messages = []

    @event_handler()
    async def send_message(self, content="", **kwargs):
        self.messages.append({"role": "user", "content": content})
        self.messages.append({"role": "assistant", "content": ""})

        await self.stream_start("response")

        async for token in llm_stream(content):
            self.messages[-1]["content"] += token
            await self.stream_text("response", token)

        await self.stream_done("response")

2. Add Stream Targets in Your Template

<div id="chat-messages">
    {% for msg in messages %}
        <div class="message {{ msg.role }}">{{ msg.content }}</div>
    {% endfor %}
</div>

<div dj-stream="response" dj-stream-mode="append"></div>

Core Methods

All streaming methods are async and require a WebSocket connection.

stream_text(stream_name, text, mode="append", target=None)

Stream plain text to a target element. Ideal for token-by-token LLM output.

async for token in llm_stream(prompt):
    await self.stream_text("output", token)

stream_to(stream_name, target=None, html=None)

Send a streaming partial update. If html is provided, sends it directly. Otherwise, re-renders the target fragment from the current template context.

await self.stream_to("messages", target="#message-list")
await self.stream_to("output", html="<p>Processing...</p>")

Prefer passing html= for a single element. The no-html= form re-renders the entire template and regex-extracts the target element — convenient, but it does a full render per call and is easy to double-write against the main diff. If you already have the fragment (e.g. from render_markdown(...)), pass it as html=.

stream_insert(stream_name, html, at="append", target=None)

Insert HTML into a stream container without replacing existing content.

await self.stream_insert("messages",
    html='<div class="msg">New message</div>',
    at="append")

stream_error(stream_name, error, target=None)

Display an error inline, preserving any partial content already rendered.

try:
    async for token in llm_stream(prompt):
        await self.stream_text("output", token)
except Exception as e:
    await self.stream_error("output", str(e))

stream_start(stream_name) / stream_done(stream_name)

Signal stream lifecycle. Sets data-stream-active="true" on start, removes it on done. Dispatches stream:start and stream:done DOM events.

stream_delete(stream_name, selector)

Remove a DOM element by CSS selector.

await self.stream_delete("messages", "#msg-42")

Template Directives

<!-- Mark as stream target -->
<div dj-stream="output">Initial content here</div>

<!-- Tokens accumulate (default) -->
<div dj-stream="response" dj-stream-mode="append"></div>

<!-- Each update replaces content -->
<div dj-stream="status" dj-stream-mode="replace"></div>

<!-- A target the handler ALSO re-renders (via djust_markdown, {{ var }}, etc.)
     should carry dj-update="ignore" so the main diff leaves it to the stream ops -->
<article dj-stream="reply" dj-update="ignore">{% djust_markdown reply %}</article>

Exclude a streamed target from the main diff with dj-update="ignore". If the same element is both a dj-stream target and ordinary diffed content (e.g. it wraps {% djust_markdown var %} or {{ var }}), the handler's event-completion render_with_diff() will re-write the region the stream ops just wrote — the client receives the content twice through two paths in close succession (visible duplicate/flicker). Add dj-update="ignore" to the target (checked client-side in 12-vdom-patch.js) so the stream ops are authoritative for that region.

Client-Side Events

EventDetailFires When
stream:start{stream}Stream begins
stream:text{text, mode, stream}Text content streamed
stream:update{op, stream}HTML replaced/appended/prepended
stream:error{error, stream}Error occurred
stream:done{stream}Stream completed
document.querySelector('[dj-stream="output"]')
    .addEventListener('stream:done', (e) => {
        console.log('Stream finished:', e.detail.stream);
    });

Full Example: LLM Chat with Error Handling

class AIChat(LiveView):  # streaming is built in — no StreamingMixin needed
    template_name = 'ai_chat.html'

    async def mount(self, request, **kwargs):
        self.messages = []
        self.is_streaming = False

    @event_handler()
    async def ask(self, content="", **kwargs):
        if self.is_streaming:
            return

        self.messages.append({"role": "user", "content": content})
        self.is_streaming = True
        await self.push_state()

        await self.stream_start("response")
        response_text = ""

        try:
            async for token in call_llm(content):
                response_text += token
                await self.stream_text("response", token)

            self.messages.append({"role": "assistant", "content": response_text})
            await self.stream_done("response")
        except Exception as e:
            await self.stream_error("response", f"Error: {e}")
        finally:
            self.is_streaming = False
<div id="chat">
    {% for msg in messages %}
    <div class="message {{ msg.role }}">{{ msg.content }}</div>
    {% endfor %}
    <div dj-stream="response" dj-stream-mode="append" class="message assistant"></div>
</div>

<form dj-submit="ask">
    <input type="text" name="content" placeholder="Ask something..."
           {% if is_streaming %}disabled{% endif %}>
    <button type="submit" {% if is_streaming %}disabled{% endif %}>Send</button>
</form>

Best Practices

  • Always wrap streaming loops in try/except and call stream_error() on failure to preserve partial content.
  • Use stream_start() and stream_done() to bracket streams so the client can show loading states via data-stream-active.
  • Use stream_text() for plain text (LLM tokens) and stream_insert() when you need HTML structure (log lines, chat bubbles).
  • Apply overflow-y: auto with a max-height on stream containers for auto-scroll behavior.
  • You do not need to throttle on the server side -- the client batches rapid updates to ~60fps automatically.
  • Mark a dj-stream target dj-update="ignore" when the handler also re-renders it, so the main diff and the stream ops don't both write the same region (see Template Directives).
  • Streaming needs an async def handler with explicit stream_* calls. Mutating a public attribute in a @background loop does not stream — the client only sees the result after the whole callback returns — and a sync @background loop cannot be interrupted mid-run (use async def so a sibling "Stop" event can flip a flag between awaits).

Spotted a typo or want to improve this page? Edit on GitHub →