We posted an update to a record, the form cleared, and the update was gone. It was still in the database, but it had vanished from the screen. The list above the form still showed yesterday’s entries, and the thing we’d just typed was nowhere. We reloaded the page and there it was, sitting at the top, exactly where it should have been. The database had it the whole time. The screen never got the memo.

Two bugs were hiding in that one moment, and both came from the same wrong assumption: that an AI write is like any other write. It isn’t. The save didn’t just persist a row, it kicked off a background GPT call that re-decided the record’s state. The model owned the outcome, and we were building the UI as if we did.

The setup

Each “node” in this project tracks a task or idea, and posting an update to a node runs an inference pass. The handler does three things in order: infer, persist, then patch the parent node with whatever the model decided. An inactive item might become active again because the update mentions a deadline. A node’s state can flip based on text we haven’t read yet. The LLM call sits in the critical path between “user hit save” and “we know what this record now looks like.”

The naive React Query mutation we’d written did what those mutations usually do. It fired the request, cleared the form, and trusted the cache. For a normal CRUD write you’d reach for optimistic UI here: render the new row immediately from the local payload, then reconcile with the server response. Snappy, standard, correct most of the time.

It is wrong here.

Why optimistic UI breaks on AI writes

Optimistic UI works when the client already knows the result. You set done: true, you render the checkbox checked, the server agrees, and the round trip goes unnoticed. The client is the source of truth for that change, so the optimism is safe.

An AI mutation inverts that. The whole point of the call is that the server (really the model behind it) computes something the client cannot predict: the suggested title, the inferred type, the new lifecycle state, the importance and priority scores. If we optimistically render the update, we have to render something, and the only something we have is a guess about what the model will say. When the model disagrees, the UI flickers from our fake to the real answer, and the user watches the record change shape for no reason they can see. That flicker is worse than waiting. It reads as a bug even when the data is correct, because the interface promised a result it didn’t have.

So the rule we landed on: AI mutations are not optimistic-UI candidates. The model owns the outcome, and you don’t fake an outcome you don’t own.

Fix one: invalidate the list

The invisible-entry half of the bug was the simpler half, and the more common one. After the mutation resolved, we never invalidated the list query. The new update was persisted server-side and the parent node was patched, but the client cache for “updates on this node” still held the pre-save snapshot. React Query had no reason to refetch, so it didn’t.

The fix is one line in the mutation’s success handler: invalidate the updates list query so it refetches. Once the inference finishes and the row is real, you throw away the stale cache and pull the truth. There is no optimistic insert, no client-side splice into the array, no guessing where the new row sorts. You invalidate and let the server report the current state, including whatever the model changed on the parent node.

This is the part people skip because optimistic UI usually papers over it. If you optimistically insert the row, you never notice you forgot to invalidate, because your fake row is already sitting there. Strip out the optimism and the missing invalidation becomes a visible bug immediately. The fake hides the gap.

Fix two: an honest “Analyzing” state

The second fix was the one that made the form feel right. While the inference runs, the UI shows an explicit “Analyzing” state. A generic spinner could mean anything from “saving” to “the network died,” and a fake-instant render might be wrong. A specific label tells the user what is happening: the model is thinking about what you just wrote.

This matters more than it looks. A spinner says “wait.” “Analyzing” says “wait, and here is why, and the thing you’re waiting for might change what you see.” It sets the expectation that the result is being computed rather than merely fetched. When the record comes back with a different state than the user might have assumed, that is the analysis they watched happen. The honest label earns you the right to reconcile afterward without it feeling broken.

It also reframes latency. Users have started to accept slow AI calls, but only when the slowness has a name. An unlabeled two-second pause reads as a stall. A two-second “Analyzing” reads as the product working.

The shape of an AI mutation

Put together, the pattern for any mutation that hands the outcome to a model looks like this:

  1. User submits. Show an explicit “Analyzing” (or “Thinking”, or whatever names the actual work) state. Do not optimistically render a result you don’t have.
  2. The server runs inference, persists, and applies whatever the model decided to the affected records.
  3. On success, invalidate the relevant queries and refetch. The refetch, and no client-side guess, renders the new state.
  4. Reconcile. Whatever the model changed (state, type, ordering) arrives in the refetch and the UI reflects reality.

The mental model is reconcile after, predict never. You tell the user the answer is being decided, then show it the moment it exists.

Where this generalizes

Any stack and any app can hit this. Any time a write triggers a model call that changes the record, the same two failures are waiting: the entry that goes invisible because nothing invalidated the cache, and the interface that claims to be instant because someone reached for the optimistic pattern out of habit.

A few places to watch for it: a support ticket that gets auto-categorized on submit, where the category column changes after the model runs. A document upload that gets summarized and tagged, where the tags don’t exist at insert time. A form that runs moderation or classification before deciding whether the record is even visible. In every one of those, the client does not know the post-write state, so faking it means faking data.

Ask whether the client can compute the result of the mutation by itself. If yes, optimistic UI is fine, so render away. If the answer lives inside a model call, it can’t, so show a thinking state and reconcile after. Get this wrong and users stop trusting that what they saved is what got saved, and a feature that works perfectly starts to feel unreliable. For an AI feature, where trust is already the thing you’re fighting for, that is the expensive failure.

What to check before you ship the save flow

Before building the save flow for an AI feature, ask whether the client can compute the result of the write by itself. If it can’t, show a named state such as “Analyzing” and refetch the affected data once the server finishes, rather than rendering a guess. A missing cache invalidation is easy to hide behind a fake row, so check that a saved change appears on screen without a page reload.