Optimistic UI is an architectural decision, not a minor UX tweak
Raffi • September 27, 2026
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.
Most frameworks make it look like a one-liner. With Inertia (Laravel + Vue) it really is:
router.post(`/mailbox/${account}/actions`, { uids, action: 'star' }, {
optimistic: (props) => ({
messages: starred(props.messages, uids),
}),
})
So it looks like a UI tweak. It isn't. It's the moment your app stops doing one thing at a time.
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,
})
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)
// The server's toast replaces the page's, or turns it into an error.
return back()->with('toast', Toast::success($message)->toArray($request->toastId()));
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) }) })
}
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.
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
],
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 })
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)}`
}
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.
Rulrmail is open source: free as in freedom, and free as in beer. Contributions and feedback are welcome.