when the interface shouldn't wait on the server
A like button that only reacts after three hundred milliseconds of network latency feels sluggish, even if the server technically responds fast. Optimistic UI updates solve this perception problem by having GraphQL mutations show the expected result instantly in the interface and only roll back cleanly on failure, instead of making the user wait for every interaction.
Table of Contents
- 1. Why optimistic UI is more than cosmetic polish
- 2. optimisticResponse: anticipating the expected result
- 3. Cache updates: keeping normalized objects consistent
- 4. Optimistic updates for lists: insert, delete, sort
- 5. Rollback: what actually happens on failure
- 6. Temporary IDs and the problem of generated keys
- 7. UX feedback: optimistic doesn't mean invisible
- 8. Testing optimistic updates deliberately
- 9. Optimistic UI compared to other loading strategies
- 10. Summary
- 11. FAQ
1. Why optimistic UI is more than cosmetic polish
People consciously perceive delays starting around a hundred milliseconds, past four hundred milliseconds an interaction feels noticeably sluggish. A typical GraphQL mutation round trip over a mobile network often lands exactly in that critical range. Optimistic UI updates don't solve this problem with faster servers, but with a different order of operations: the interface shows the expected result of a mutation right away, while the actual request runs in the background. To the user, every interaction feels instant, regardless of actual network latency.
The mistake many teams make on their first use of optimistic UI updates is assuming it's a purely cosmetic optimization. In reality, the approach fundamentally changes the error behavior of the whole application. If a mutation fails after the UI has already shown the optimistic result, the application needs to roll that state back cleanly, without leaving the user confused. Without a well-thought-out rollback mechanism, interfaces end up briefly showing wrong data and leave users unsure what actually happened.
Apollo Client ships with built-in support for optimistic UI updates with GraphQL mutations, via the optimisticResponse parameter and its tight coupling to the normalized cache. The following sections show how to correctly apply this mechanism for single objects, lists, and more complex scenarios involving temporary IDs, and where the typical pitfalls lie.
2. optimisticResponse: anticipating the expected result
The core of optimistic UI updates in Apollo Client is the optimisticResponse parameter on useMutation. It describes exactly the shape the server is expected to return, including __typename for every affected object, so Apollo Client can correctly place the result into the normalized cache. As soon as the mutation fires, Apollo Client applies this anticipated result immediately, every component reading the affected data via useQuery re-renders instantly.
It's important that optimisticResponse structurally matches the actual mutation result exactly, including every requested field. If a field is missing or the type doesn't match, you either get cache inconsistencies or runtime errors when merging in the real response. For simple toggle actions like a like, this is usually straightforward, because the result can be unambiguously derived from the current state.
// Optimistic response for a simple toggle mutation
const LIKE_POST = gql`
mutation LikePost($postId: ID!) {
likePost(postId: $postId) {
id
likedByMe
likeCount
}
}
`;
function LikeButton({ post }: { post: { id: string; likedByMe: boolean; likeCount: number } }) {
const [likePost] = useMutation(LIKE_POST, {
variables: { postId: post.id },
// The UI updates instantly, before the server actually responds
optimisticResponse: {
likePost: {
__typename: 'Post',
id: post.id,
likedByMe: !post.likedByMe,
likeCount: post.likedByMe ? post.likeCount - 1 : post.likeCount + 1,
},
},
});
return <button onClick={() => likePost()}>{post.likeCount} Likes</button>;
}
3. Cache updates: keeping normalized objects consistent
Apollo Client's normalized cache stores objects by a combination of __typename and id. As long as the optimistic response references exactly the same object already in the cache, Apollo Client automatically updates every component reading that object via any query, regardless of the query path through which it was originally loaded. This automatic normalization is why optimistic UI updates with GraphQL work so well, a like on a post simultaneously updates the feed view and the detail view, without manual synchronization code.
Things get more complex when a mutation affects fields not directly included in its return value, such as a derived counter on a parent object. For such cases, Apollo Client offers the update function, which grants direct cache access and can apply arbitrary additional changes while the mutation runs. This function runs both for the optimistic response and the real server response, ensuring consistency between the two phases.
// Manual cache update for a derived field not returned by the mutation
const [addComment] = useMutation(ADD_COMMENT, {
optimisticResponse: {
addComment: {
__typename: 'Comment',
id: `temp-${Date.now()}`,
text: commentText,
author: currentUser,
},
},
update(cache, { data }) {
// Increment the parent post's comment count, not returned by the mutation itself
cache.modify({
id: cache.identify({ __typename: 'Post', id: postId }),
fields: {
commentCount: (existing: number) => existing + 1,
},
});
},
});
4. Optimistic updates for lists: insert, delete, sort
Updating a single object optimistically is the easy case, lists are harder because Apollo Client doesn't automatically alter list fields by default when a new element is added. For optimistic UI updates on lists, an explicit update function is therefore almost always needed, one that inserts the new element into the existing list reference in the cache, in the right position, with correct ordering.
Deletion needs the reverse consideration: an optimistically deleted element must be removed from every list it's referenced in, not just the list through which the delete action was triggered. When a comment appears both in a paginated overview and a filtered view, both cache references need updating in sync, otherwise the deleted element briefly still shows up in one of the two views, which users perceive as a bug.
// Optimistically inserting a new item into a cached list
const [addTodo] = useMutation(ADD_TODO, {
optimisticResponse: {
addTodo: {
__typename: 'Todo',
id: `temp-${Date.now()}`,
text: newTodoText,
completed: false,
},
},
update(cache, { data }) {
if (!data) return;
cache.modify({
fields: {
todos(existingTodos = []) {
const newTodoRef = cache.writeFragment({
data: data.addTodo,
fragment: gql`
fragment NewTodo on Todo {
id
text
completed
}
`,
});
// Prepend, matching where the real server response will place it
return [newTodoRef, ...existingTodos];
},
},
});
},
});
5. Rollback: what actually happens on failure
The most important difference between a naive and a clean implementation of optimistic UI updates shows up on failure. Apollo Client automatically rolls back a failed optimistic mutation, the UI reverts to the last known, actually server-confirmed state as soon as the mutation completes with an error. This rollback happens without extra code, as long as error handling doesn't accidentally lock in the optimistic state manually.
A common mistake is making additional local state updates in the onCompleted callback that don't get automatically reverted on rollback, because they live outside the Apollo cache, such as in local component state. For consistent behavior, any UI state depending on an optimistic mutation should be read directly from the Apollo cache, rather than duplicated in separate state. It's also worth adding a visible error notification in the onError callback, so the user doesn't perceive the rollback as an inexplicable disappearance of their action.
6. Temporary IDs and the problem of generated keys
When creating new objects, the client doesn't yet know the final, server-assigned ID at the time the optimistic response is applied. A client-generated temporary ID, usually with a clearly recognizable prefix like temp-, bridges that gap. For optimistic UI updates, it's crucial that Apollo Client correctly replaces the temporary object with the real object bearing the final ID once the real server response arrives, rather than adding it as a second, duplicate entry.
This swap works reliably as long as the update function manipulates the list via a stable reference rather than by ID, Apollo Client then handles rewriting the reference from the temporary to the final ID itself. Without this mechanism, or if the ID is compared manually in local state, visible duplicates appear that only vanish on the next full list reload, a classic, hard-to-find production bug.
7. UX feedback: optimistic doesn't mean invisible
Optimistic UI updates don't mean network status should stay completely invisible. For uncritical, frequent actions like a like, no additional feedback is needed at all, the optimistic state is self-explanatory. For actions with bigger consequences, such as deleting a record or a financial transaction, a subtle indication that the action isn't yet finally confirmed server-side should remain visible despite the optimistic display, such as a small sync icon or reduced opacity until confirmation arrives.
This balance matters, because too much visible loading feedback negates the benefit of optimistic UI updates, while too little feedback on critical actions costs trust when an error actually occurs and the user had no warning at all. The rule of thumb: the bigger the consequence of a possible failure, the more visible the provisional nature of the display should remain.
8. Testing optimistic updates deliberately
Optimistic UI logic is notoriously hard to test manually, because the interesting case, a server error after a successful optimistic display, rarely occurs on its own in a development environment. For optimistic UI updates, it therefore pays off to build a dedicated test setup with Apollo Client's MockedProvider, simulating a failing mutation response and verifying the UI correctly returns to its previous state.
A sensible test pattern covers three cases: the optimistic intermediate state right after triggering the mutation, the final state after a successful server response, and the rollback state after a simulated error. Together, these three snapshots ensure the optimistic UI logic works not only on success, but also reacts correctly in the user-visible failure case.
9. Optimistic UI compared to other loading strategies
Optimistic UI is one of several strategies for reducing perceived latency. The following overview ranks the approaches by response speed and implementation risk.
| Strategy | Perceived latency | Error risk | Effort |
|---|---|---|---|
| Wait + spinner | High | Low | Low |
| Skeleton loading | Medium | Low | Medium |
| Optimistic UI (simple) | Very low | Medium | Medium |
| Optimistic UI (lists + rollback) | Very low | Higher without care | High |
The effort for optimistic UI updates rises noticeably once lists, temporary IDs and rollback logic come together, but the perceptual gain for users almost always justifies that effort for frequently used, interactive elements.
Mironsoft
GraphQL architecture and React frontend performance
Want your interface to feel instantly responsive?
We implement optimistic UI updates with correct cache handling, rollback behavior, and tests for the critical failure cases, instead of cosmetic quick fixes.
Mutation design
optimisticResponse and cache updates for single objects and lists
Rollback safety
Error handling that never leaves the user in the dark
Test coverage
MockedProvider tests for optimistic state, success and rollback
10. Summary
Optimistic UI updates with GraphQL mutations let interfaces react instantly, with the optimisticResponse parameter anticipating the expected result and Apollo Client updating the normalized cache accordingly. For single objects, this mechanism is usually enough, for lists an explicit update function is additionally needed, one that inserts new elements correctly and removes deleted elements from every affected cache reference.
The decisive difference between a robust and a fragile implementation lies in the failure case: automatic rollback only works if UI state is consistently read from the Apollo cache rather than duplicated in separate local state. Temporary IDs for newly created objects, visible but subtle feedback for critical actions, and deliberate tests for the rollback case round out a clean implementation of optimistic UI updates.
Optimistic UI Updates — The essentials at a glance
optimisticResponse
Anticipates the expected mutation result, must structurally match the real response exactly.
Cache updates
update function for lists and derived fields not directly included in the mutation result.
Rollback
Automatic with Apollo Client, reliable only without duplicated local state.
Temporary IDs
Clearly recognizable prefix, automatically replaced once the real response arrives.