Optimistic Updates

The problem

I will discuss a very important user interface pattern called optimistic update. It is something which we see and use in our day-to-day life but we never realize it.

Almost everyone uses Instagram. On Instagram, when you press the like button, the photo or reel gets liked instantly, whether you double-tap the post or you press the like button directly. The feedback of the post getting liked is very instant. The count also gets updated instantly.

Now if you close your Instagram and open it again, or if you refresh your feed, the post which you liked is still liked by you, which means that the like is not local to your phone. The like must be saved somewhere on a server, and saving something to a server takes a few hundred milliseconds on a fast connection and a few seconds on a bad one.

The obvious way to build a like button is as follows.

  1. User taps
  2. You send the request
  3. When the server replies, you fill the heart and update the like count

We have a problem here, which is that now every tap has a delay, because the user is waiting for the server to reply, and on a slow connection that waiting could be long, which makes the app feel slow and some users tap again because nothing seemed to happen.

So this is where the optimistic update concept comes in.

What is an optimistic update

An optimistic update means that whatever you are updating, you update it instantly assuming that the server has already said yes, and you send the request in the background. It is called optimistic because you are assuming the request will succeed. For an action like a like, this assumption is right almost every time because a request rarely fails. The only extra work is the failure case: if the server says no, you put the screen back the way it was and tell the user, which is not complex either. I will demonstrate both cases below using code and an interactive demo.

When the server says yes

Click the heart in the demo below and watch the count change before the request dot reaches the server.

server

Click the heart. The count updates before any request is sent.

In code, the optimistic part is simple. You update the local state before the request fires instead of after the response comes back:

const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
function toggleLike() {
setLiked(!liked);
setCount(liked ? count - 1 : count + 1);
api.setLike(postId, !liked);
}

This version works well until a request fails. When that happens, the heart stays filled and the count stays wrong, so the screen is showing something which is not true. This is why the optimistic part alone is not enough for production.

When the server says no

The missing piece is rollback: putting the screen back to what it was before. Save the old value before you change anything. In the demo below, the server rejects every request. Click the heart and watch the update happen first, then get taken back when the failure arrives.

server

Click the heart. The count updates first; the failure arrives after.

The production version saves the previous state, restores it in the catch block, and tells the user:

LikeButton.jsx

function LikeButton({
postId,
initialLiked,
initialCount,
}) {
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
const [saving, setSaving] = useState(false);
async function toggleLike() {
if (saving) return;
const previous = { liked, count };
setSaving(true);
setLiked(!previous.liked);
setCount(previous.count + (previous.liked ? -1 : 1));
try {
await api.setLike(postId, !previous.liked);
} catch {
setLiked(previous.liked);
setCount(previous.count);
showToast('Could not save your like');
} finally {
setSaving(false);
}
}
return (
<button onClick={toggleLike} aria-pressed={liked}>
<HeartIcon filled={liked} /> {count}
</button>
);
}

There are three details which matter here. First, the rollback restores the saved value instead of guessing, so the screen goes back exactly where it started. Second, the catch block tells the user, because a heart which quietly unfills looks like a bug. Third, the saving flag ignores taps while a request is already running, so two requests can never run at the same time and overwrite each other.

You don't always have to write this by hand. React ships useOptimistic for exactly this, and server state libraries build the same save and rollback into their mutations: see TanStack Query and SWR. The state management tools article covers where those libraries fit.

When to use it

Optimistic updates fit actions which succeed almost every time and are easy to undo. These are the four you will meet most often.

  • Likes, reactions and upvotes. When you press like on Instagram or LinkedIn, the heart fills and the count goes up right away, and the request is sent after that. This is the example we used in this article.
  • Sending a chat message. When you send a message on WhatsApp, it appears in the chat immediately with a small pending tick. The tick changes once the server has saved it. Here you can actually see the waiting part, which you cannot see with a like.
  • Moving a card on a board. When you drag a card to another column in Jira or Trello, the card stays where you dropped it. You have already moved it with your hand, so sending it back to wait for the server would feel broken.
  • Delete with undo. When you archive a mail in Gmail, the row goes away at once and you get an Undo option. This is safe because Undo lets you bring it back if something goes wrong.

Don't use them when a wrong yes is expensive: payments, deleting something you cannot restore, or anything the user would act on believing it succeeded. For those, wait for the server and show a pending state instead.

Remember

An optimistic update assumes the server will say yes. So before you change anything, save the old value, restore it exactly if the request fails, and tell the user when you do.