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

6 min read

Tutorial: Optimistic UI updates with @optimistic

The cheapest way to make a UI feel fast is to lie a little. When the user clicks "Like", flip the heart immediately — don't wait the 80 ms it takes the WebSocket round-trip to confirm. If the server later disagrees (rate-limited, deleted, permission-denied), correct the UI then. Most of the time it won't disagree, so most of the time the lie holds and the click feels instant.

djust ships an @optimistic decorator that does exactly this:

  • The client mutates the DOM immediately based on the event data, before sending anything over the WebSocket.
  • The framework still ships the event to the server, runs the handler, computes the real diff, and patches the DOM — so any correction the server makes overwrites the optimistic guess.

By the end of this tutorial you'll have a todo list where:

  • Toggling a todo's done state flips the checkbox + line-through immediately. No spinner, no delay.
  • The server still updates the DB, and the next render reconciles any disagreement.
  • A rate limiter kicks in after 5 toggles in 10 seconds — when it does, the optimistic flip is automatically reverted by the server's authoritative diff. The user sees a brief incorrect state and then the truth.
  • Toggling while offline doesn't break — when the WebSocket reconnects, the queue flushes and the server reconciles.
You'll learnDocumented in
@optimistic decorator behaviorAPI: Decorators
When NOT to use optimistic updatesThis tutorial
Pairing @optimistic with @rate_limitJS Commands
Reconciliation model (client guess → server authority)This tutorial

Prerequisites: Quickstart, the search-as-you-type tutorial (sets up the debouncing vocabulary). Familiarity with @event_handler helps.


When @optimistic earns its keep

The decorator is a perf trick, not a correctness one. Use it when:

  • The action's result is determined by the event data alone — toggling, liking, voting, marking-as-read. The server can't say anything the client doesn't already know.
  • The failure cases are rare (rate limits, auth checks, race conditions). The cost of a brief wrong-then-right flicker is lower than the cost of every action feeling sluggish.
  • The action is idempotent or self-healing — re-applying the server's authoritative diff has to land you in a consistent state regardless of the client's guess.

Don't use it when:

  • The result depends on server-side logic the client can't predict (assigned-id, computed total, derived field).
  • The failure case is common (e.g. a typeahead lookup that often returns no match — the user would see a flicker on every character).
  • The user would be confused by the flicker more than helped by the speed (sensitive financial / medical confirmations).

Step 1 — The model

Standard Django:

# myapp/models.py
from django.conf import settings
from django.db import models


class Todo(models.Model):
    title = models.CharField(max_length=200)
    done = models.BooleanField(default=False)
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
        related_name="todos",
    )

    class Meta:
        ordering = ["done", "-id"]

Step 2 — The view, with @optimistic + @rate_limit

# myapp/views.py
from djust import LiveView, state
from djust.decorators import event_handler, optimistic, rate_limit

from .models import Todo


class TodoListView(LiveView):
    template_name = "todos.html"

    todos = state(default_factory=list)

    def mount(self, request, **kwargs):
        if not request.user.is_authenticated:
            self.redirect("/login/")
            return
        self._refresh()

    def _refresh(self):
        self.todos = [
            {"id": t.id, "title": t.title, "done": t.done}
            for t in Todo.objects.filter(user=self.request.user)
        ]

    @event_handler
    @optimistic
    @rate_limit(max_calls=5, per_seconds=10)
    def toggle_todo(self, todo_id: int = 0, **kwargs):
        todo = Todo.objects.get(id=todo_id, user=self.request.user)
        todo.done = not todo.done
        todo.save()
        self._refresh()

Three decorators stacked, in this exact order:

  1. @event_handler — the bottom of the stack. Marks the method as callable from the client.
  2. @optimistic — runs above the handler. Tells the client to apply the visual update immediately based on the event data, then await the server's authoritative diff.
  3. @rate_limit(max_calls=5, per_seconds=10) — runs above that. After the 5th call in 10 s, the handler raises before doing any DB work; the server then ships a diff that reflects the un-toggled state, and the client's optimistic flip is reverted automatically.

Decorator order matters: @optimistic must be inside the @event_handler (closer to def) for the framework to wire the client-side prediction. Reversed, the optimistic metadata never reaches the client.


Step 3 — The template

<!-- myapp/templates/todos.html -->
<ul class="todos">
  {% dj-for todo in todos %}
    <li class="todo {% if todo.done %}is-done{% endif %}">
      <button
        type="button"
        dj-click="toggle_todo"
        data-todo-id="{{ todo.id }}"
        aria-pressed="{{ todo.done|yesno:'true,false' }}"
        class="todo-toggle"
      >
        <span class="todo-check" aria-hidden="true">
          {% if todo.done %}✓{% else %}&nbsp;{% endif %}
        </span>
        <span class="todo-title">{{ todo.title }}</span>
      </button>
    </li>
  {% end-dj-for %}
</ul>

The two pieces that make optimistic UI work:

PieceRole
{% dj-for todo in todos %} with stable data-todo-idThe framework's per-row diff sees that "the todo with id=N changed its done field" rather than "the whole list changed", so the optimistic patch can be applied to a single row.
class="{% if todo.done %}is-done{% endif %}"The CSS state hook the client toggles. The framework knows from the @optimistic metadata + the data-todo-id payload which row to flip and which classes/text reflect done state, so it patches optimistically before the server responds.

Step 4 — The CSS

.todo {
  display: flex;
  align-items: center;
  padding: 8px 12px;
  border-bottom: 1px solid var(--color-border, #e5e7eb);
  transition: opacity 0.15s;
}
.todo.is-done {
  opacity: 0.55;
}
.todo.is-done .todo-title {
  text-decoration: line-through;
}
.todo-toggle {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 1;
  background: transparent;
  border: 0;
  padding: 0;
  text-align: left;
  cursor: pointer;
}
.todo-check {
  display: inline-flex;
  width: 20px;
  height: 20px;
  align-items: center;
  justify-content: center;
  border: 1.5px solid var(--color-muted, #9ca3af);
  border-radius: 4px;
  font-weight: 600;
}
.todo.is-done .todo-check {
  background: var(--color-accent, #10b981);
  border-color: var(--color-accent, #10b981);
  color: white;
}

opacity and text-decoration transitions are intentional — they make the flip feel acknowledged without being abrupt. If you set transition: none, the flip is sharper and the rare server- correction is more visible.


Step 5 — Try it, including the failure path

# myapp/urls.py
from django.urls import path
from .views import TodoListView

urlpatterns = [path("todos/", TodoListView.as_view())]

Visit /todos/ and click rapidly:

  • The first 5 clicks within 10 s flip instantly. Each round-trip also lands a server-authoritative diff that matches what the client predicted, so nothing visibly changes on confirmation.
  • The 6th click in that window also flips instantly (optimistic). ~60 ms later, the server's RateLimitedError is dispatched, the framework computes the diff against the un-toggled state, and the client patches the row back. The user sees a brief wrong state then the truth.

That brief wrong state is the design — the framework can't suppress it without holding every optimistic update for at least one round-trip, which would defeat the purpose. If you need the flicker hidden, use a normal @event_handler and accept the ~80 ms baseline latency.


What just happened, end to end

   Browser                    Client runtime              Server
      │                              │                       │
      │ click toggle (id=42)         │                       │
      │ ──────────────────────────►  │                       │
      │                              │  apply optimistic     │
      │                              │  patch: row 42 → done │
      │ ◄ DOM patch (instant) ────── │                       │
      │                              │  send event over WS   │
      │                              │ ────────────────────► │
      │                              │                       │  Todo.objects.get()
      │                              │                       │  todo.done = True
      │                              │                       │  todo.save()
      │                              │                       │  diff template
      │                              │ ◄──── authoritative ──│
      │                              │       patch
      │                              │  diff against         │
      │                              │  optimistic state →   │
      │                              │  no-op (matches)      │
      │ (no further DOM change)      │                       │

When the rate limit kicks in:

      │ click toggle (6th in 10s)    │                       │
      │ ──────────────────────────►  │                       │
      │ ◄ DOM patch (instant) ────── │  optimistic flip      │
      │                              │ ────────────────────► │
      │                              │                       │  RateLimitedError
      │                              │ ◄──── authoritative ──│  (no DB change)
      │                              │       patch (revert)
      │ ◄ DOM patch (revert) ─────── │                       │
      │  (brief wrong → corrected)   │                       │

Where to go next

  • Pair with JS Commands for non-state optimistic UI (close a modal instantly, then save). JS Commands cover client-only state the server doesn't care about; @optimistic covers state the server will eventually authoritatively confirm.
  • Add a "Saving…" indicator for actions where the optimistic flip isn't fully sufficient (e.g. server-assigned id). Show a small inline spinner from the optimistic flip until the server diff lands; hide on confirmation.
  • Reconciliation logging — in dev, the latency simulator lets you add artificial WS delay to verify your optimistic UI handles real-world network conditions.
  • Don't optimistic over auth boundaries — if the action requires the user to be logged in and you're not 100% sure they still are, leave it as a normal @event_handler so the auth-redirect doesn't flicker through a fake "success" state first.

The @optimistic recipe — three stacked decorators (@event_handler

  • @optimistic + optional @rate_limit) — is the same shape every "this should feel instant" interaction uses: likes, votes, follows, mark-as-read, archive, star, mute. Once you see the flicker happen once when the server disagrees, the model clicks.

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