← All posts

Optimistic updates: a like button that responds instantly and can roll back

I know this habit of mine: if a button doesn’t react right away, I press it again. On a fast connection the habit never surfaces — the response lands before my finger comes back up. On mobile data somewhere between subway stations, there’s a noticeable gap between tapping a like and seeing the heart fill in, and for that whole gap the interface looks broken. Worse, the second tap may well reach the server as a second request — and remove the like the first one just added.

The scheme that behaves this way is the most common one: click → request → response → update the UI. It’s built honestly, in that it only ever shows what the server has confirmed. The price of that honesty is a network round-trip inside every click.

An optimistic update reorders the steps. State and UI change immediately; the request goes out afterwards, in the background. The name is accurate: we’re betting the request will succeed. For a like it’s close to a sure bet — the overwhelming majority of these requests come back fine, and the rare failure needs only a plan B: restore the previous state and say so. The whole technique is those two moves — write before confirmation, and a prepared rollback.

My context is the usual one for this blog: a server-rendered page. A blog or catalog on Rails, Laravel, WordPress — doesn’t matter; the server renders the post cards, each with a Like button and a counter, and the numbers in the markup are already real, straight from the database. All that’s missing is a thin client layer on top. I build it with Micra — a small reactivity library that works over existing server markup and doesn’t restructure any of it.

The include, so the example actually runs:

<script src="https://cdn.jsdelivr.net/npm/micra.js@2/dist/micra.min.js"></script>

Markup: the server has already rendered everything

The card comes from the server, and the server writes the initial values into both the visible text and the data-* attributes:

<!-- this fragment is server-generated; liked and likes come from the database -->
<article class="post-card">
  <h2>Post title</h2>

  <div data-component="like" data-post-id="42" data-liked="false" data-likes="17">
    <button
      @click="toggle"
      data-class="is-liked:liked"
      data-bind="aria-pressed:liked ? 'true' : 'false'"
    >
      Like · <span data-text="likes">17</span>
    </button>
    <span class="like-error" data-show="failed">Couldn't save — try again</span>
  </div>
</article>

Every card is its own instance of the like component with its own state. The 17 sits in the markup and is visible immediately, before any script loads; once Micra mounts, it picks the same values up from the attributes and manages them from there. The button reflects liked through the is-liked class and aria-pressed — both will update on any change to it, including a rollback.

The component

Micra.define("like", {
  state: { liked: false, likes: 0, failed: false },

  onCreate() {
    // the server passed the initial values as data-* attributes
    this.state.liked = this.prop("liked", false);
    this.state.likes = this.prop("likes", 0);
  },

  async toggle() {
    if (this._busy) return; // a request is in flight — ignore the second click
    this._busy = true;

    // snapshot the pre-change state — this is what we roll back to
    const snapshot = { liked: this.state.liked, likes: this.state.likes };

    // optimistic write: the UI updates now, before the request
    this.state.failed = false;
    this.state.liked = !this.state.liked;
    this.state.likes += this.state.liked ? 1 : -1;

    try {
      // background sync; this.fetch throws on any non-2xx response
      const res = await this.fetch(`/posts/${this.prop("postId")}/like`, {
        method: "POST",
        body: { liked: this.state.liked },
      });
      this.state.likes = res.likes; // the server's count is canonical
    } catch {
      // rollback: restore both fields from the snapshot
      this.state.liked = snapshot.liked;
      this.state.likes = snapshot.likes;
      this.state.failed = true;
    } finally {
      this._busy = false;
    }
  },
});
Micra.start();

That’s all of it.

onCreate reads the initial values through this.prop(): the attribute string "false" becomes a boolean, "17" becomes a number. The server stays the single source of the starting state — no duplicate JSON payload to keep in sync with the markup.

toggle begins with the snapshot. It’s a plain object holding copies of two fields — nothing more is needed for the rollback.

Then the optimistic write: liked flips, the counter moves by one. Micra re-renders everything that depends on those fields — the counter text, the button class, aria-pressed — in the same tick, without talking to the server. As far as the user is concerned, the like has already happened.

Then the request. this.fetch is a built-in wrapper around fetch: it serializes the body as JSON and throws on any non-2xx response, so a network failure and a 500 land in the same catch. It also attaches the CSRF token from <meta name="csrf-token"> automatically — on a Rails or Laravel page that tag is usually already there, and the POST goes through with no extra plumbing.

On success I accept the server’s counter: res.likes is the canonical number. While the request was in flight, someone else may have liked the post too; local arithmetic doesn’t know that, but the server does. A successful response is a convenient moment to line back up with the truth.

On failure — the rollback: both fields come back from the snapshot, and the very same bindings in the markup repaint the button to its original look. Plus the message driven by failed: a silent rollback is indistinguishable from a bug, and a short line is more honest.

Why a snapshot rather than the reverse operation

The first instinct is to skip the snapshot and, on failure, run the operation backwards: flip liked back, move the counter the other way. For one field that works. Here there are already two fields, and the reverse has to be written for each of them with every sign correct. Add a third field — say, “when the like happened” — and the reverse code has to grow again.

A snapshot doesn’t care how many fields there are. However much the state grows, the rollback stays the same: copy before the change, restore on failure.

The _busy flag closes the scenario from the first paragraph: while a request is in flight, a repeat click is ignored. For a like that’s enough — the window between click and response is short, and the impatient second tap usually means “I can’t tell whether it worked,” which the optimistic repaint removes in the first place.

How much code this is

The component is about thirty lines, comments included, and none of them update the DOM: the text, the class, and the attribute are declared in the markup as dependencies of the state, and the rollback repaints them through the same mechanism as the optimistic write — I described once how the button looks in each state, instead of writing twice how to change it there and back. JSON and CSRF are handled by this.fetch. The remaining lines are the actual solution: the moment of the write, the snapshot, the rollback. That feels like the right proportion to me — the code restates the technique almost word for word.

When this scheme won’t do

Blocking for the duration of the request is a blunt instrument. Apply the same scheme to something clicked rapidly — a quantity stepper, a settings toggle — and you can’t drop clicks; you have to accept them all and sync the final state to the server with a debounce. That’s a different recipe, and noticeably more code.

Optimism isn’t appropriate everywhere. For a like, the cost of being wrong is a heart that blinks back. For a payment, a deletion, a submitted application, the order is the opposite: server confirmation first, celebratory UI second. You choose the technique by the cost of a false success, and for a like that cost is minimal.

And scale. One optimistic mutation in an isolated component is precisely the size of problem where a snapshot and a try/catch suffice. When such mutations multiply and share data across screens — with retries, queues, offline support — you want a full client-side data layer, the kind that lives in the big frameworks’ ecosystems. Micra doesn’t pretend otherwise: its territory is islands of interactivity on a server-rendered page, and a like with a rollback fits on that territory whole.

The recipe in the docs has a list variant too, where a feed of posts renders through data-each and rolls back by replacing a single array.