•
4 min read
the client already knows what just happened
graphql apollo frontend

When a user acted in the shared workspace, saved a listing, reacted, commented, the activity feed showed the update about eight seconds later. The event flowed from the client to the backend, onto Kafka, through a consumer, into the feed store, and back to the client on the next refetch. Correct, durable, and eight seconds of “did that work?” for the person who had just tapped the button.

The task was to make the user’s own actions feel instant without rebuilding the pipeline.

the insight

The client already knows everything about the event it just produced. It knows the actor, the target, the type, the timestamp, and the text. Waiting eight seconds for the server to tell it what it already knows is waiting for confirmation, and confirmation is a different requirement from display.

So the fix writes a synthetic event into the Apollo normalized cache at interaction time. The feed re-renders instantly from local knowledge. The real event arrives through the pipeline later and reconciles with the synthetic one. The Kafka pipeline stayed exactly as it was: durability unchanged, perception fixed, zero backend changes.

Eight seconds to instant, measured before and after.

why the pipeline was eight seconds

Not a bug. Batching and consumer lag by design. That pipeline serves durability and fan-out to other users, and those properties are worth seconds of latency. What it was never designed for is the one reader who does not need fan-out or durability to see their own action: the person who performed it. Knowing which property you need where is the whole story.

WebSockets or subscriptions would have delivered the same perceived result at far higher cost, and they would still have been a round trip for an event that needed none.

the cache is a store rather than a response log

This works because Apollo Client does not cache responses. It normalizes them. Every object with a __typename and an id is flattened into a store keyed by those two things, and queries become views over that store.

Query.feed  ->  [ Event:1, Event:2, Event:3 ]
Event:1     ->  { __typename: "Event", id: "1", kind: "SAVE", ... }

Change Event:1 in the store and every query holding a reference to it re-renders. Insert a new Event and add its reference to the feed list, and the feed re-renders with it. You do not have to know which components show the feed, and you do not have to refetch.

It also explains the most common cause of an optimistic update silently doing nothing: an object with no id, or a missing __typename, has nothing to normalize against, so the write lands somewhere no query is looking.

only one of apollo’s cache writes rolls back

Apollo gives you two ways to render before the server confirms, and they behave differently when something goes wrong.

optimisticResponse on a mutation writes the fake result into a separate optimistic layer on top of the cache. When the real response arrives, the layer is discarded and the real data is written. If the mutation errors, the layer is discarded and nothing is written. It is transactional by construction.

An update function, or a direct cache.modify or writeQuery, writes into the cache itself. If you use it to insert the synthetic event and the mutation then fails, the event is still there. Nothing rolls it back, because nothing recorded that it was provisional.

The feed fix uses both. The optimistic response carries the synthetic event so a server rejection removes it automatically. The update function runs on both the optimistic and the real result and does the same thing each time: add the event’s reference to the feed list if it is not already there. Written that way, the same function is idempotent across the optimistic pass, the real pass, and the pipeline’s eventual refetch, and the reconciliation is a set membership check rather than a merge.

what to know before copying this

The synthetic event and the real event have to be the same cache entity, which means the client needs the server’s id or a stable client-generated one the server will echo. Without that, the feed shows the action twice: once synthetic, once real.

A no-cache fetch policy on any query that reads the feed bypasses the store entirely and will never see the synthetic write. That is not a bug in the fix. It is the fetch policy doing what it says, and it is worth checking every reader of the feed for before shipping.

The durable pipeline still does everything it did. This is not a shortcut around it. It is a decision about which reader needs which property, and the answer for the one person who already knows what happened is: none of them.