An interaction feels instant or it does not, and the line is somewhere near a hundred milliseconds. A round trip to your api is usually more than that. So anything that waits for the server to confirm before it renders will feel slow, no matter how fast the server is.
Apollo gives you two ways to render before the server has confirmed anything. They look similar in a component. They behave completely differently when something goes wrong, and that difference is worth understanding before you reach for either.
the cache is a store, not a response log
Apollo Client does not cache responses. It normalizes them. Every object with a __typename and an id gets flattened into a flat store keyed by those two things, and queries become views over that store.
Query.feed -> [ Note:1, Note:2, Note:3 ]
Note:1 -> { __typename: "Note", id: "1", body: "...", likes: 4 }
This is the whole reason any of what follows works. If you change Note:1 in the store, every query holding a reference to it re-renders with the new value. You do not have to know which components are showing that note, and you do not have to refetch anything to find out.
It also explains the most common cause of optimistic updates silently doing nothing: if your object has no id, or you forget the __typename, there is nothing to normalize against, so the write lands somewhere no query is looking.
the easy case
You add a note. You know what the server is going to say, so say it early:
const [addNote] = useMutation(ADD_NOTE, {
optimisticResponse: {
addNote: {
__typename: "Note",
id: `temp:${crypto.randomUUID()}`,
body,
likes: 0,
},
},
});
Apollo writes that into the cache immediately, re-renders anything reading it, and when the real response arrives it replaces the temporary object with the real one. If the mutation fails, the write disappears.
That last sentence is doing a lot of work, and it is the part people remember incorrectly.
where it stops being enough
optimisticResponse patches what the mutation returns. It does not know your note belongs in a list, because nothing in the mutation result says so.
So for anything list-shaped you write the cache yourself:
const [addNote] = useMutation(ADD_NOTE, {
optimisticResponse: { /* as above */ },
update(cache, { data }) {
cache.modify({
id: cache.identify(thread),
fields: {
notes(existing = [], { toReference }) {
return [toReference(data.addNote), ...existing];
},
},
});
},
});
toReference matters here. You want a pointer to the normalized object, not a second copy of it embedded in the list. Two copies means two sources of truth, and they will disagree eventually.
There is a second situation that pushes you here, and it is the one worth planning for. If your read model is populated asynchronously, by an event bus or any pipeline that runs after the write commits, then the mutation can succeed, return correctly, and the query the ui actually reads from still will not reflect it for a while. The server is not wrong and the client is not wrong. The data simply is not there yet.
You end up constructing the entry the pipeline is eventually going to produce, and writing it into the cache yourself so the user sees their own action immediately.
the difference that bites
Here is the thing I would want to have known earlier.
The optimistic write is transactional. The update write is not.
Apollo keeps optimistic data in a separate layer stacked on top of the real cache. On error, or when the real response lands, that layer is discarded and the cache is recomputed from what is underneath. You get rollback for free.
But update also runs when the real response arrives, and at that point you are writing to the underlying cache directly. Nothing is watching it. There is no layer to discard. If you write something wrong there, it stays wrong until something else overwrites it or the page reloads.
This means an update function that is not careful about being run more than once, or that derives a value from something already optimistically written, can leave permanent garbage in the store. The symptom is a count that drifts a little every time you perform the action, or an item that appears twice, and it never reproduces on the first try, because the first try is the one that behaves.
The habit that avoids it: write update so that running it twice with the same data produces the same cache. Prefer replacing over appending, and deduplicate by reference when you cannot.
two more sharp edges
A no-cache query cannot see any of this. fetchPolicy: "no-cache" skips the normalized store entirely, so it will not observe optimistic writes and will not re-render when you modify the store. If one screen updates instantly and another shows stale data after the same action, check the fetch policy on the one that did not move before you go looking at your cache logic.
Evicting an object does not remove pointers to it. If you cache.evict a normalized object that a list still references, the list is left holding a reference that resolves to nothing. Apollo calls these dangling references and mostly handles them, but a field read that assumes the object exists will not. If you evict, prune the lists that point at it in the same transaction, or filter with canRead:
cache.modify({
fields: {
notes(existing = [], { canRead }) {
return existing.filter(canRead);
},
},
});
when not to do this
Optimistic updates are a claim about the future. They are worth making when you are confident about what the server will say, and the cost of being briefly wrong is a flicker.
They are a bad idea when the server is the thing that decides. Anything that can be rejected by a rule the client does not know about, anything where the server assigns the meaningful value, anything involving money. Showing the user a result and then taking it back is worse than making them wait, and it is much worse if they have already moved on to the next thing.
The useful test is not “can i make this instant”. It is “if this turns out to be wrong, how does the user find out, and does the correction still make sense by the time it arrives”.