Frontend//8 min read/Next.js/Performance/Engineering

The like button that lied to me on purpose

I turned the network off, liked my own post, and watched the count go up anyway. The lie was deliberate and correct here. Where it stops being correct is the whole question.

SharePostLinkedIn

I turned the network off, opened one of my own posts, and hit like. The count went from 11 to 12 straight away. Then I reloaded, and it was 11 again.

Nothing had gone wrong. The button did exactly what I wrote it to do, and what I wrote was a catch block containing a comment that says the failure is fine.

So my own site had lied to me, on purpose, with my signature on it.

The interesting question is not whether that is a bug. For a like button I think it is the right call. What matters is where that stops being true, because the same three lines somewhere else would be a genuinely bad thing to ship.

The short version

  • Optimistic rendering shows the expected result on click, then sends the request. It exists because 400ms of nothing reads as broken, not as slow.
  • Make the bet only when the client can derive the answer, being wrong is cheap, and a later moment corrects it for free.
  • Decide what a lost bet looks like before you ship the won one. Rollback is honest and looks like a glitch. Keeping the lie is comfortable and is sometimes indefensible.
  • Never bet on money, uniqueness, permission, or anything the server decides on its own.
  • Prefer useOptimistic, because it rolls back whether or not you remembered to.
  • A like button may lie for a few hundred milliseconds. Almost nothing else on your site has earned that.

That is the whole answer. The rest is how I got there, and why the third one is the hard part.

What optimistic rendering actually is

Any button that talks to a server has to pick one of two moments to update the screen: when the server answers, or when the click happens.

Waiting for the answer is the honest one, and it is what code does by default. You send the request, you get a number back, you show that number. What is on screen is what is in the database, always.

An optimistic update is the other choice. You change the screen the instant the click lands, using the result you expect, and send the request afterwards. Most of the time the server agrees and nobody ever finds out the screen was ahead of it.

The ordinary version of this is a waiter. You order the fish, and they write it down and say "fish, lovely" before anybody in the kitchen has heard a word about it. You believe your dinner is coming. You are almost always right. Occasionally they come back to say the fish is off, and the entire arrangement depends on that apology being possible.

Why waiting is worse than it sounds

A round trip is not slow by any human standard. Call it 200ms on a good connection and 600ms on a train.

The trouble is where those numbers sit. Nielsen's response-time limits put 100ms as the point where something stops feeling instant and starts feeling like a machine you are waiting on. Interaction to Next Paint draws its own line at 200ms.

So a button that sits still for 400ms does not read as slow. It reads as broken, and people click it again. I have already written about the two frames it takes to look dead, and that post ends with a double delete.

The version that feels broken

Here is the honest one:

async function vote(kind) {
  const next = await react(slug, kind);
  setState(next);
}

Three lines, completely correct, and nobody can ever be misled by it. The number on screen came from the database.

It also feels terrible. You press the thumb and nothing happens, and then a moment later it happens, and the two events do not feel connected to each other.

The version that lies

Here is what actually ships on this site:

const was = myReaction;
const optimistic = was === kind ? null : kind;
 
setState((prev) => {
  const next = { ...prev };
  if (was === "like") next.likes -= 1;
  if (optimistic === "like") next.likes += 1;
  next.myReaction = optimistic;
  return next;
});
 
startTransition(async () => {
  const next = await react(slug, kind);
  setState(next);
});

The first half computes the answer I expect and shows it. The second half goes and asks.

One property is carrying all the weight here, and it is easy to read past: the client can work out the answer on its own. A reaction toggles. Pressing like when you already liked something removes it, pressing it otherwise adds it, and there is nothing the server knows that changes that. The guess is not really a guess.

sequenceDiagram
  participant U as You
  participant B as Browser
  participant S as Server
  U->>B: click like
  B->>B: 11 becomes 12 (this frame)
  B->>S: react(slug, "like")
  Note over B,S: 200ms to 600ms of the screen being ahead
  S-->>B: { likes: 12 }
  B->>B: replace the guess with the answer

That middle stretch is the whole subject. For a fifth of a second, sometimes longer, the screen is showing something nobody has agreed to yet.

The line that decides everything

Every optimistic update owes an answer to one question: what happens when the bet loses.

There are two answers, and picking between them is the actual design work.

try {
  const next = await react(slug, kind);
  setState(next);
} catch {
  /* keep the optimistic value; a reload will reconcile */
}

The first answer is rollback. Put the number back. It is honest, and it looks awful: the count goes 11, 12, 11 while somebody is looking straight at it, with no explanation attached. Users read that as a glitch even when it is the truthful thing to do.

The second is to keep the lie and let the next page load fix it. Written out plainly, that decision says: this reader will see a number one higher than reality until they reload. Nobody is harmed, nothing downstream depends on it, and reconciliation happens for free the moment they come back.

I picked the second one. For a like button on a personal blog, I would pick it again. Put those same three lines around a payment and I should not be allowed near a keyboard.

When the bet is worth making

Three conditions, and the update needs all three.

You can predict the answer. Not guess it, derive it. If the client can compute the post-click state from what it already holds, you are not really being optimistic, you are being early.

Being wrong is cheap. Measure that in what the reader loses if the screen was wrong for a second, not in how likely it is.

The truth arrives anyway. There has to be a later moment that quietly corrects things, and it has to be one people actually reach.

This is why Notion feels the way it does. A page there is a tree of blocks, and typing a character is a small mutation to one of them, applied to the local copy first and synced afterwards. Every keystroke passes all three tests without trying.

You know what the character will look like because you just typed it. Being wrong costs one letter. And the document is yours, so nothing is coming later to disagree.

Push that far enough and you arrive at local-first software, where the copy on your machine is the real one and the server is just a place it syncs to. Optimistic rendering is the shallow end of that pool.

When not to make it

Never bet on an outcome the server owns. Availability, price, permission, uniqueness, order. If the answer depends on a row you cannot see or another person you are racing, you do not have a prediction, you have a hope.

The concrete cases, all of which look tempting:

  • Money. A charge that appears to succeed and later did not is the worst version of this. Stripe's whole idempotency design exists because retries on money are hard even when everybody is being careful.
  • Claiming something unique. A username, a handle, a seat. Two people can both be optimistic about the same one.
  • Anything a moderator or a rule can reject. You do not know the rule. The server does.
  • Anything that leaves the page. If the person navigates away before the answer lands, the correcting moment never comes, and the lie is permanent.

There is a fourth test hiding in that last one. An optimistic update is only safe if the reader is still around to see it corrected.

What to actually do

React ships the primitive now, and it is better than hand-rolled state because it makes rollback the default instead of something you remember:

const [shown, addOptimistic] = useOptimistic(
  likes,
  (current, delta: number) => current + delta
);

useOptimistic ties the guess to the action. While the action is in flight you see shown, and the moment it settles the value reverts to the real prop on its own. You cannot forget to undo it, because undoing it is not something you do. Outside React actions, TanStack Query's optimistic updates do the same job with an explicit rollback in onError.

Then write the failure case down before you write the success case. If you cannot say in one sentence what the reader should see when the request fails, you are not ready to be optimistic yet.

The engagement bar on this page is in the repo, comment and all.

4views

Written by Niraj Jha

Liked this? Get new posts by email.

Comments

No account needed. Pick a name and say what you think.

0/2000

Keep reading

  • FeaturedAI
    /13 min

    The same question, four thousand ways

    A support bot answered ninety questions four thousand times and paid full price for every one. Exact-match caching hit two percent. Here is what actually works.

    • AI
    • Engineering
    • Performance
  • Next.js
    /6 min

    Why your loading.tsx never showed up

    The skeleton took an afternoon and nobody ever saw it. The file was in the right folder with the right name, and four separate things stopped it rendering.

    • Next.js
    • Performance