Showing changes instantly while the actual server request runs in the background
Every click that only shows a visible effect after a server round trip feels sluggish to users, even when the request objectively only takes two hundred milliseconds. Optimistic UI updates solve this perception problem by having the Alpine state show the expected change immediately, with the actual server request only running afterward in the background. Using a like button as an example, this article shows how to implement such a pattern cleanly, which rollback strategy kicks in for a failed request, how to avoid race conditions across several fast interactions, and in which cases optimistic updates should deliberately be avoided.
Table of Contents
- 1. The problem with noticeable latency
- 2. The core principle of optimistic updates
- 3. Implementing a like button with an optimistic update
- 4. Rollback strategy for a failed request
- 5. Optimistic updates for lists: the shopping cart example
- 6. Avoiding race conditions on fast repeated clicks
- 7. Visual feedback while waiting
- 8. When optimistic updates should deliberately be avoided
- 9. Testing considerations for optimistic UI logic
- 10. Summary
- 11. FAQ
1. The problem with noticeable latency
People perceive a response time under a hundred milliseconds as instant, at two hundred to three hundred milliseconds a noticeable feeling of delay already sets in, and around one second the user's attention gets measurably interrupted. A like button that only changes state after the fetch request completes often lands in exactly that critical range, even with a good server connection, particularly on mobile connections with higher latency.
The actual problem is not the real server time, it is the perception of responsiveness. An interface that acknowledges every interaction immediately feels noticeably smoother than one that waits for every response before showing anything, even at identical server performance. Optimistic updates close exactly that perception gap.
2. The core principle of optimistic updates
The base pattern is straightforward: the Alpine state gets changed immediately, as if the server request had already succeeded, and only afterward is the actual request sent off. For the user, the change is instantly visible, while the real confirmation from the server is still pending in the background.
That ordering differs fundamentally from a classic pessimistic update pattern, where the state only changes once the server response has succeeded. Optimistic UI assumes a high probability of success for the request, because on failure the already visible change has to be rolled back, which requires a clean rollback and should be communicated in the interface itself.
3. Implementing a like button with an optimistic update
In practice, this means toggling liked and adjusting likeCount right at the click, before any fetch call has even started. Only afterward is the request sent to the server, whose response, on success, merely confirms the state already shown, without the user noticing anything.
It matters to cache the previous state before the change, so it can be restored exactly on failure. A plain toggle without a stored previous state happens to work by accident for a boolean, but with more complex changes like a counter adjustment it easily produces wrong values when several interactions happen in quick succession.
Alpine.data('likeButton', (postId, initialLiked, initialCount) => ({
liked: initialLiked,
likeCount: initialCount,
pending: false,
async toggleLike() {
const previousLiked = this.liked;
const previousCount = this.likeCount;
// Optimistic update: visible instantly, before the request even starts
this.liked = !this.liked;
this.likeCount += this.liked ? 1 : -1;
this.pending = true;
try {
const response = await fetch(`/api/posts/${postId}/like`, {
method: this.liked ? 'POST' : 'DELETE',
});
if (!response.ok) {
throw new Error(`Unexpected status: ${response.status}`);
}
} catch (error) {
// Roll back to the state before the optimistic update
this.liked = previousLiked;
this.likeCount = previousCount;
this.$dispatch('toast', { type: 'error', text: 'Could not save the like.' });
} finally {
this.pending = false;
}
},
}));
4. Rollback strategy for a failed request
A reliable rollback needs two pieces: the cached previous state and a try-catch block that covers every failure case, both network errors and non-successful HTTP status codes. Since fetch does not produce a rejected promise on a 404 or 500, response.ok has to be checked explicitly, and an error thrown manually if needed, for the catch block to trigger at all.
Beyond simply resetting the state, a good rollback strategy also includes a visible message to the user, for example a short toast explaining that the action could not be saved. Without that feedback, the snapping-back state looks to the user like a display glitch rather than a deliberate error handling step, which undermines trust in the interface.
5. Optimistic updates for lists: the shopping cart example
For list operations such as adding an item to the shopping cart, a plain boolean toggle is no longer enough. Instead, a temporary object with a client-generated, provisional ID is inserted into the list immediately, while the actual request runs in the background, and on success the provisional ID gets replaced with the real, server-assigned one.
If the request fails, the item gets removed from the list by its provisional ID, rather than resetting the entire list, which could otherwise cause data loss if other operations completed in parallel in the meantime. This ID-based approach is more robust than plain index access, since an item's position in the list can shift due to other operations completing in between.
async addToCart(product) {
const tempId = `temp-${crypto.randomUUID()}`;
this.cartItems.push({ id: tempId, ...product, pending: true });
try {
const response = await fetch('/api/cart/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: product.sku, qty: 1 }),
});
const saved = await response.json();
const item = this.cartItems.find((entry) => entry.id === tempId);
Object.assign(item, saved, { pending: false });
} catch (error) {
this.cartItems = this.cartItems.filter((entry) => entry.id !== tempId);
}
}
6. Avoiding race conditions on fast repeated clicks
If a user clicks the same like button several times in quick succession before the first request finishes, two parallel requests can arrive at the server in an unpredictable order and produce a different final state there than what the client last showed optimistically. A simple, often sufficient safeguard is a pending flag check that blocks further clicks while the previous request is still in flight.
For cases where blocking is not acceptable UX, such as a quantity stepper, an AbortController helps, actively cancelling a still-running previous request as soon as a new one starts, combined with a server-side incrementing version or sequence number so outdated responses can be detected and discarded even if they still arrive at the client.
async updateQuantity(newQty) {
this.abortController?.abort();
this.abortController = new AbortController();
const previousQty = this.quantity;
this.quantity = newQty; // optimistic
try {
await fetch(`/api/cart/items/${this.itemId}`, {
method: 'PATCH',
body: JSON.stringify({ qty: newQty }),
signal: this.abortController.signal,
});
} catch (error) {
if (error.name !== 'AbortError') {
this.quantity = previousQty;
}
}
}
7. Visual feedback while waiting
Even though the change itself is visible instantly, it should still be recognizable to the user that a confirmation is pending in the background, especially for actions whose rollback would later stand out unpleasantly. A subtle pattern is a reduced opacity or a small loading indicator while pending is true, without blocking the interaction itself.
It matters to keep that feedback subtle and not confuse it with a classic, blocking loading state. The whole point of optimistic updates is that the interface feels instantly responsive, an overly intrusive pending indicator would undo exactly that effect.
8. When optimistic updates should deliberately be avoided
For payment-related actions such as confirming an order or a bank transfer, an optimistic update is fundamentally the wrong choice, since a rollback after apparent success massively undermines the user's trust here and can, in the worst case, lead to conflicting displays between the client and the actual account balance. For such actions, a classic, pessimistic pattern with a visible loading state is the far safer choice.
Restraint also pays off for actions with a high probability of failure, for instance due to frequent server-side validation errors, because an interface that snaps back often feels more disruptive than a short but honest wait. Optimistic UI works best for low-risk actions with a high probability of success, where an occasional rollback remains the exception.
9. Testing considerations for optimistic UI logic
Testing an optimistic component means it is not enough to only check the success case, since the actual value of the pattern only shows up in the failure case. A meaningful test simulates a failing fetch call and then checks whether the state was reset exactly to the previous value, not just roughly or to a plausible-looking substitute.
It is also worth adding a test for race condition handling, simulating two fast, consecutive actions and checking that the actually intended final state is shown at the end, rather than an intermediate state from a discarded, older request. These tests cover exactly the class of bugs that is easy to miss during manual testing on a stable internet connection.
| Aspect | Optimistic UI | Pessimistic UI | Recommendation |
|---|---|---|---|
| Perceived speed | Instantly visible | Waits for the server response | Optimistic UI for low-risk actions |
| Error handling | Requires rollback logic | No rollback needed | Always cache the previous state |
| Payment-related actions | Not suitable | Suitable | Always use pessimistic UI |
| Race conditions | Requires active protection | Rarely relevant | Use an AbortController or a pending flag |
| Implementation effort | Higher, due to rollback | Lower | Only use it for a real UX gain |
Mironsoft
Alpine.js interactivity for Hyvä frontends
A Hyvä frontend that needs more interactivity, but without React overhead?
We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.
Custom Components
Develop interactive Alpine.js components for specific shop requirements.
Performance Review
Review existing Alpine.js implementations for reactivity pitfalls and performance.
Team Training
Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.
10. Summary
Optimistic UI Updates with Alpine.js
Core idea
The Alpine state shows the expected change instantly, while the actual server request only runs afterward in the background.
Rollback obligation
The previous state must be cached before every optimistic update, so it can be restored exactly on failure.
Race condition protection
A pending flag or an AbortController prevents conflicting states on fast repeated clicks.
Clear boundary
For payment-related or error-prone actions, a pessimistic pattern with a visible loading state is safer.