A UX Guide for Async Backends: Optimistic, Decoupled, or Neither
A pragmatic guide for designers working with async backends: three interaction patterns, when to use each, and four anti-patterns to push back against.
Spinners Are a Bug Report From Your Backend
An event-based backend moves work out of the request/response cycle: the client submits, the server accepts and enqueues, and the state change happens some time later. A spinner signals that the system is actively producing the result for this session; but in an event-based flow there is no in-progress work on the client to wait on. The UI conflates two distinct states (the backend has queued the work versus the backend is computing the result right now), and those two states carry different promises to the user.
The gap between when the backend records a change and when the frontend reflects it is architecturally required. Closing that gap with a spinner is the wrong move: the spinner either resolves before the state arrives (so the user sees an empty list after a "created" confirmation) or it hangs past the session.
This post is a guide for UX designers working with async backends. It covers two usable answers: optimistic UI for reversible actions, and decoupled notifications for everything else; plus four anti-patterns (including the refresh loop) that look like fixes but aren't.
Three Choices: When To Use Each
When a designer works with an async backend, there are three interaction patterns to choose from.
Optimistic UI is the default for any reversible action. Decoupled notifications are the default for anything slow, irreversible, or likely to outlive the session. Blocking spinners are almost always wrong; they lock the UI, force the user to abandon the task, and the backend still has to be async-safe underneath. Any work long enough to justify a spinner belongs in the decoupled path.
The decision turns on one question: if the server rejects the action, can the user undo it right there on the screen?
The tree is rooted at the default. Everything else is a named override.
Designing Optimistic UI: Four States
Optimistic UI is a four-state machine. A designer needs a screen treatment for each; the one that gets skipped is usually rollback.
Idle. The default state. Button enabled, form valid, cursor ready. The user has not done anything yet.
In-flight / Optimistic. The moment the user clicks. Show the result as if it succeeded, but with a quiet hedge: slight opacity, a small "sending" glyph on the item (not a full-screen spinner), secondary actions like "undo" disabled. The user feels the success; a visual cushion is left in case of rollback.
Confirmed. The server came back green. The hedge drops. The transition should be almost invisible; the user already believes it succeeded; now you're making it real.
Rolled-back. The server said no. Undo the change, but visibly and on the item itself. No toast; a "failed to send, tap to retry" line next to the row, in place. The user's mental model: "the thing I did is right here, I can try again from the same spot."
Three rules tie the four states together:
- Toasts are not for rollback. Toasts disappear; the user needs to see what failed and what they can do. The error belongs on the item, in place.
- Failure visuals should match success visuals. Same row, same affordance; only the status label flips.
- Retry lives where the action lived. The user should not have to navigate away and come back.
Designing the Decoupled Flow: Two Channels and a Nest
In decoupled flows, the action leaves the user's sight. The UI has three responsibilities: was it accepted, where will it appear, and how does the user get told when it's done?
1. The acknowledgment screen. After submit, move the user to an "accepted" screen. In the copy, "Accepted" or "Got it," not "Success" or "Created." An ETA if you can give one: "This usually takes about 2 minutes." And a wayfinding cue: "You can follow it on the My Orders page."
2. A pending badge in the list. Booking, upload, report, whatever it is, it should appear in the list immediately, visibly marked as pending: grayed out, an "in progress" badge, an inline progress bar if the work is measurable. The user sees it's there; the user sees it isn't real yet. The refresh loop ends here.
3. The notification surface. When the work is done, tell the user. In-app toast if they're still in the app; push if they're backgrounded; email as fallback. Every surface needs copy for success and failure: "Your booking is ready: view it" and "Your booking couldn't complete: here's why and what you can do."
4. A status page. For any work over 30 seconds, give the user a dedicated page to come back to. Every notification should deep-link there. The page should show the full history: submitted → processing → completed / failed.
One rule ties the four together: pending cannot stay pending forever. A row that has been "processing..." for 30 minutes is a bug, not a feature. At design time, set a time limit; when that limit is exceeded, show a clear error or provide an escalation path.
Four Anti-Patterns To Push Back Against
-
A full-screen spinner over a long operation. It locks the UI, forces the user to abandon the task, and leaves no way back. Anything over three seconds should be pulled into the decoupled flow.
-
A fake "success" toast. "Your order has been placed" followed by an email that says "payment declined." Accepted ≠ completed. Match the copy to the truth: "received" is one word, "done" is another.
-
An error toast that fires after the user left the page. The toast appears 3 seconds later; the user is on another screen. Errors should attach to the item the action belongs to, not to the page. Use an in-place status change on the item, not a floating toast.
-
State drift between the detail screen and the list screen. Modal says "created," user navigates to the list, it isn't there. The classic read-after-write gap. Either insert it optimistically in the list, or keep the modal open until the list has refreshed. The user should not have to hunt for "where did it go?" after hitting Done.
Closing
When the backend is async, the designer's job is to design the gap. There are three options: optimistic if it's reversible, decoupled if it isn't, a brief loading for genuinely short sync operations. The blocking spinner isn't on the list: it's neither honest about what the backend is doing nor kind to the user waiting for it.
Pick deliberately. Name the default, name the overrides, and build the rollback screen before shipping the happy path. Users shouldn't have to silently compensate for a gap with a refresh loop.
The backend side of the seam (the transport choice) lives in the companion post: Async API Patterns for Web and Mobile.
References
- Jakob Nielsen: Response Time Limits - The 100ms / 1s / 10s thresholds that ground every UX latency budget.
- Stripe: PaymentIntents lifecycle - The canonical state machine for an async payment UX (processing, requires_action, succeeded).
- Figma: How Figma's multiplayer technology works - How optimistic UI carries the feel of real-time collaboration.
- Reverse engineering Linear's sync magic - The optimistic layer behind Linear's "instant" feel.
- Google: How to unsend an email in Gmail - The canonical "optimistic UI for an irreversible action via a delay window" pattern.
- Temporal: Handling Signals, Queries, and Updates - Vocabulary for exposing long-running work progress to a UI.