Ask
28

Optimistic update makes the UI flicker through a wrong state when the request fails - what is the correct rollback?

Added optimistic updates to a toggle. Happy path is lovely. The failure path is worse than what I had before.

What happens when the request fails: the toggle flips back, then briefly flips again, then settles. Sometimes it settles on the wrong value and stays there until I navigate away and come back.

I am writing to the cache in the mutation and rolling back in the error handler by writing the old value back. That seems to be roughly what the docs show and it clearly is not enough.

I suspect the problem is that a refetch is landing somewhere in the middle, but I cannot work out the ordering. What is the pattern that actually gets this right?

8 answers Share
Report

Answering anonymously, a moderator will review it first.

  • @rollback_reference · 3w ago · 3 replies

    Your suspicion is right, and there are two separate bugs producing the two symptoms.

    The flicker is an in-flight refetch landing on top of your optimistic write. You write the optimistic value; a query that was already running finishes and writes the server's old value; your error handler then writes your snapshot. Three writes, and the middle one is not yours.

    The fix is to cancel outgoing queries for that key before you write optimistically. Every good implementation of this starts with that step and it is the one people skip because the happy path works without it.

    The wrong final value is your rollback writing a stale snapshot. If you captured the old value when the mutation was defined rather than immediately before the write, or if two mutations overlap, the value you restore is not the value that was there.

    So the correct sequence, in order, and the order is the whole answer:

    1. Cancel in-flight queries for the affected key
    2. Read the current cached value and keep it as the rollback snapshot
    3. Write the optimistic value
    4. On error, write the snapshot back
    5. On settled: success or error - invalidate the key so the server has the last word

    Step 5 is what fixes the state that persists until you navigate away. Without it, a failed rollback is never corrected by anything.

    30
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
    • @flicker_fixed · 3w ago · 2 replies

      Rewrote this three times and the version that finally held was the one that stopped treating rollback as a separate step: capture the previous value when the optimistic update goes on, restore exactly that on failure, and only then let anything refetch.

      The flicker is almost always something refetching between the rollback and the settle, so you see server, then guess, then server again.

      22
      Share
      Reply

      Answering anonymously, a moderator will review it first.

      Report
      • @rollback_reference · 2w ago

        Capture, restore, then invalidate. Doing the invalidate first is what produces the second flip.

        13
        Share
        Reply

        Answering anonymously, a moderator will review it first.

        Report
  • @server_is_truth · 3w ago · 2 replies

    The principle underneath the sequence above, which makes it easy to remember: the optimistic value is a guess with a deadline, and the server response ends it either way.

    A lot of the mess comes from treating the mutation's own response as the new truth and writing it into the cache instead of refetching. That works until the server does something you did not model - a computed field, a timestamp, a side effect on a related record - and now your cache is a plausible fiction. Invalidating and letting the query refetch is slightly slower and always correct.

    Two places this shows up:

    Lists. Optimistically adding a row means inventing an id. When the server responds with the real one, you either reconcile carefully or you refetch. Refetch, unless you have measured that you cannot afford to.

    Anything with ordering or aggregation. A toggle that changes a count somewhere else is exactly where hand-maintained cache updates go wrong, because you have to remember every derived thing.

    The rule I use: optimistically update for feel, invalidate for truth. The optimistic write buys you the instant response; the invalidation makes sure that after a second, what is on screen is what is in the database. If those two ever disagree permanently, you have a bug you cannot see.

    25
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
    • @flicker_fixed · 3w ago

      A guess with a deadline is the right mental model. It is not state, it is a claim waiting to be confirmed.

      10
      Share
      Reply

      Answering anonymously, a moderator will review it first.

      Report
  • @same_key_twice · 3w ago

    Settling on the wrong value is the more serious of the two symptoms and it is the one people notice last.

    7
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
  • @one_key_per_thing · 3w ago · 2 replies

    Worth checking one thing before you rewrite anything: is the key you are writing to the key the component is reading from?

    A surprising share of "the rollback did not work" is that the optimistic write went to one key and the component is subscribed to another, a list key versus a detail key, or a key with a filter object that is constructed inline and therefore not identical between renders. The write succeeds, nothing complains, and the component never sees it. Then the eventual refetch corrects the screen and it looks like a timing bug.

    How to check quickly: open the devtools for your query library and watch the cache while you toggle. You will see whether the optimistic write is landing on the entry the component uses. Thirty seconds and it rules out the entire category.

    The related trap is a key built from an object literal. If any part of it is created fresh each render, or contains something non-serialisable, you can end up with more cache entries than you think - and your optimistic write goes to one while the screen reads another.

    If your toggle affects both a list and a detail view, you need to handle both, and that is the case where invalidating on settled saves you from writing two rollbacks that have to agree.

    1
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
    • @same_key_twice · 3w ago

      Check the key before rewriting any of it. I spent a week on rollback logic when the actual bug was that the mutation wrote to a key with a filter in it and the list read from the key without.

      Everything looked correct in isolation. The optimistic write simply landed somewhere nothing was reading.

      18
      Share
      Reply

      Answering anonymously, a moderator will review it first.

      Report