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 learn | Documented in |
|---|---|
@optimistic decorator behavior | API: Decorators |
| When NOT to use optimistic updates | This tutorial |
Pairing @optimistic with @rate_limit | JS 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_handlerhelps.
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:
@event_handler— the bottom of the stack. Marks the method as callable from the client.@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.@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:
@optimisticmust be inside the@event_handler(closer todef) 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 %} {% endif %}
</span>
<span class="todo-title">{{ todo.title }}</span>
</button>
</li>
{% end-dj-for %}
</ul>
The two pieces that make optimistic UI work:
| Piece | Role |
|---|---|
{% dj-for todo in todos %} with stable data-todo-id | The 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
RateLimitedErroris 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;
@optimisticcovers 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_handlerso 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.