---
title: "Tutorial: Optimistic UI updates with @optimistic"
slug: tutorial-optimistic-updates
section: guides
order: 66
level: intermediate
description: "Build a todo list where toggling a todo's done state flips the UI instantly — before the server has even seen the click. The server still authoritatively confirms (or rejects with a rollback) on the next round-trip. Uses the @optimistic decorator and explains the reconciliation model."
---

# 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](/api/decorators/) |
| When NOT to use optimistic updates | This tutorial |
| Pairing `@optimistic` with `@rate_limit` | [JS Commands](/guides/js-commands/) |
| Reconciliation model (client guess → server authority) | This tutorial |

> **Prerequisites:** [Quickstart](/getting-started/), the [search-as-you-type
> tutorial](/guides/tutorial-search-as-you-type/) (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:

```python
# 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

```python
# 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

```html
<!-- 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:

| 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

```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

```python
# 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](/guides/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](/guides/developer-tools/) 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.
