DEV Community

Raffi Hovhannesian
Raffi Hovhannesian

Posted on Originally published at rulr.dev

Optimistic UI is an architectural decision, not a minor UX tweak

While building Rulrmail, an open-source, email-first CRM, I wanted one small thing: when you star an email, the star should light up now, not after a round trip to a mail server somewhere.

The technique has a friendly name: optimistic UI. It means the interface acts on what you asked for straight away, as if the server had already said yes. The request goes off in the background, and in the rare case the server says no, the interface quietly undoes the change and tells you why.

Restaurant waiter and optimistic UI
Same slow kitchen. You just didn't have to watch it. (Out of salmon? He comes back and says sorry. That's the rollback.)

Waiting UI vs Optimistic UI

Most frameworks make it look like a one-liner. In case of Inertia (Laravel + Vue) it really is:

router.post(`/mailbox/${account}/actions`, { uids, action: 'star' }, {
    optimistic: (props) => ({
        messages: starred(props.messages, uids),
    }),
})
Enter fullscreen mode Exit fullscreen mode

So it looks like a UI tweak. It isn't. It's the moment your app stops doing one thing at a time.

Where instant cracked layer by layer

1. Clicks start overlapping

Before, every click waited its turn. Now you can star three messages in a second. By default, a new visit cancels the one in flight, and the screen briefly "un-stars" the first message.

The fix is to let requests run side by side:

router.post(url, data, {
    async: true,          // don't cancel the other clicks
    preserveState: true,  // keep selection, scroll, open panes
    optimistic: () => changes,
})
Enter fullscreen mode Exit fullscreen mode

2. Feedback lags behind the change

The star was instant, but the little "Starred · Undo" toast still waited for the server. The screen said done while the confirmation said wait.

The fix flips who's in charge. The page shows the toast itself, with an id, and sends that id along. The server answers with the same id, so its toast updates the one on screen instead of adding a second one.

const toastId = uuid()
toasts.show({ id: toastId, message: 'Starred 1 message.', undo: pending })
router.post(url, { ...data, toast_id: toastId }, options)
Enter fullscreen mode Exit fullscreen mode
// The server's toast replaces the page's, or turns it into an error.
return back()->with('toast', Toast::success($message)->toArray($request->toastId()));
Enter fullscreen mode Exit fullscreen mode

3. Users act before the server has

With an instant Undo, people click it before the original change has even reached the server. There's nothing to undo yet.

So the page puts things back immediately, remembers the request, and sends the real undo the moment the server's answer arrives:

if (toast.undo.token === null) {
    waitingForToken.set(toast.id, restore)  // send it when the answer comes
    router.replace({ props: (current) => ({ ...current, ...restore(current) }) })
}
Enter fullscreen mode Exit fullscreen mode

4. Shared state starts racing

Two requests in flight at once meant a message the server had stored in the session "for the next page load" got picked up by the wrong request and vanished. Anything that quietly assumed one request at a time broke. The cure is the same as above: the page keeps what it needs instead of relying on server leftovers.

Shared state explained at the kitchen pass
"For whoever asks next" only works when one person is asking. (The fix: write the table number on the plate. That's why every toast now has an id.)

5. The database notices

Even the local database complained. SQLite allows one writer at a time, and our parallel requests collided until database is locked showed up in the logs.

You might say: who cares about SQLite, that's just local development. Well, I do. It's a red flag anyway: if two requests can collide on my laptop, they can collide in production too, just less often and much harder to reproduce. Local is where that kind of bug is cheapest to catch. A few settings fixed it: wait for the lock instead of failing, let reads and writes happen side by side, and take the write lock up front.

// config/database.php
'sqlite' => [
    // …
    'busy_timeout' => 5000,            // wait for the lock, don't fail
    'journal_mode' => 'wal',           // readers and the writer don't block each other
    'synchronous' => 'normal',
    'transaction_mode' => 'IMMEDIATE', // a read that becomes a write can't wait
],
Enter fullscreen mode Exit fullscreen mode

6. The last details: the illusion is fragile

A loading bar for a change that's already on screen tells users you don't believe your own UI:

router.post(url, data, { showProgress: false, optimistic: () => changes })
Enter fullscreen mode Exit fullscreen mode

And a browser API that only exists on HTTPS (crypto.randomUUID()) silently broke every action on a plain-HTTP dev domain, while the tests, running on 127.0.0.1 (which counts as secure), stayed green:

export function uuid() {
    const bytes = crypto.getRandomValues(new Uint8Array(16)) // works on plain HTTP too
    bytes[6] = (bytes[6] & 0x0f) | 0x40
    bytes[8] = (bytes[8] & 0x3f) | 0x80
    const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
    return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
Enter fullscreen mode Exit fullscreen mode

The lesson

Optimistic UI isn't a coat of paint. The moment the screen stops waiting for the server, you've built a system where:

  • requests overlap,
  • the screen and the server briefly disagree,
  • users act on things that don't exist yet,
  • and every layer, from the browser to the database, has to cope.

Plan it like an architecture change, not a feature. Decide early who owns the truth at each moment, how you roll back, and what happens when two things happen at once.

Done well, nobody notices any of this. The star just lights up. That's the point.

Top comments (0)