`useQuery` (cache-first) stays stale when an optimistic mutation is written during its own result delivery
Issue Description
A cache-first useQuery can permanently miss a cache update, even though the cache itself holds the right data.
We hit this in production. A details panel runs a useQuery for an item. An effect fires a markRead mutation, with an optimisticResponse, as soon as that query reports the item unread. The mutation succeeds and the normalized cache ends up with read: true. But the component's useQuery result keeps returning read: false until some unrelated field on the item changes, so the panel's menu kept offering "Mark read" for an item already marked read. In our case the query uses HttpLink.
It only happens when all three of these hold (each variant is in the repro below):
| Condition | Stale? |
|---|---|
cache-first query, effect fires a mutation with optimisticResponse, and the link completes one task after it emits (as a fetch-backed HttpLink does, since the body stream ends after the result is emitted) |
Yes: cache has read: true, useQuery renders false |
| Same, but the link completes in the same tick it emits | No |
Same timing, no optimisticResponse |
No |
Same timing, fetchPolicy: "cache-and-network" |
No |
What we saw when we instrumented it (offered as a pointer, not a proposed fix): the optimistic write lands while the query's own operation is still in activeOperations. It is delivered synchronously from the result the query just emitted, before that operation's finalize runs. ObservableQuery.notify() clears dirty and then returns early for a non-cache-only/cache-and-network query with activeOperations.size > 0, so that notification is discarded rather than deferred. When the real mutation result arrives it produces the same diff as the optimistic one, so no second broadcast happens and the query never catches up. The same early return is present in 4.3.0 and on main today (src/core/ObservableQuery.ts, the !dirty || (fetchPolicy !== "cache-only" && fetchPolicy !== "cache-and-network" && this.activeOperations.size) check).
In apollographql/apollo-client#11526 (https://github.com/apollographql/apollo-client/issues/11526) the intended rule was described as not delivering cache updates "before your initial network request has succeeded". Here the request has already succeeded and its result has been delivered; the write lands after that, only before the operation is cleaned up. And the update isn't delayed, it's lost for good. So this looks like the gate catching a case it wasn't meant to, rather than intended behavior.
Possibly adjacent, but a different mechanism: apollographql/apollo-client#13434 (https://github.com/apollographql/apollo-client/pull/13434) (refetch requests deduplicating against an in-flight query) doesn't touch this gate.
Link to Reproduction
https://github.com/jasonbarnett667/apollo-optimistic-write-repro
A real HttpLink over fetch in a real browser, with a tiny GraphQL endpoint served as Vite middleware. npm install && npm run dev, then open http://localhost:5193. The page links the bug case and both controls.
Reproduction Steps
Self-contained, with only @apollo/client, react, @testing-library/react and vitest. No app code and a plain InMemoryCache. Run it with vitest in a jsdom environment. The "bug" case fails and the other three pass.
import { ApolloClient, ApolloLink, InMemoryCache, Observable, gql } from '@apollo/client';
import { ApolloProvider, useMutation, useQuery } from '@apollo/client/react';
import { render, screen, waitFor } from '@testing-library/react';
import { useEffect } from 'react';
import { describe, expect, it } from 'vitest';
const ITEM = gql`
query Item {
item(id: "1") {
id
read
}
}
`;
const MARK_READ = gql`
mutation MarkRead {
markRead(id: "1") {
id
read
}
}
`;
// A link that answers after 10ms. `completeInSameTick` controls whether it
// completes in the same tick it emits, or one task later (as a fetch-backed
// HttpLink does: the body stream ends after the result is emitted).
function makeClient(completeInSameTick: boolean) {
let serverRead = false;
let mutationsSettled = 0;
const link = new ApolloLink(
(operation) =>
new Observable((observer) => {
const timer = setTimeout(() => {
if (operation.operationName === 'MarkRead') {
serverRead = true;
observer.next({ data: { markRead: { __typename: 'Item', id: '1', read: true } } });
} else {
observer.next({ data: { item: { __typename: 'Item', id: '1', read: serverRead } } });
}
const done = () => {
if (operation.operationName === 'MarkRead') mutationsSettled += 1;
observer.complete();
};
if (completeInSameTick) done();
else setTimeout(done, 0);
}, 10);
return () => clearTimeout(timer);
})
);
const client = new ApolloClient({ cache: new InMemoryCache(), link });
return { client, settled: () => mutationsSettled };
}
const VARIANT = { optimistic: true, fetchPolicy: 'cache-first' as 'cache-first' | 'cache-and-network' };
// Mark the item read as soon as the query reports it unread.
function App() {
const { data } = useQuery<{ item: { id: string; read: boolean } }>(ITEM, {
fetchPolicy: VARIANT.fetchPolicy,
});
const [markRead] = useMutation(
MARK_READ,
VARIANT.optimistic
? { optimisticResponse: { markRead: { __typename: 'Item', id: '1', read: true } } }
: {}
);
const read = data?.item.read;
useEffect(() => {
if (read === false) void markRead();
}, [read, markRead]);
return <output aria-label="read">{String(read)}</output>;
}
async function run(completeInSameTick: boolean) {
const { client, settled } = makeClient(completeInSameTick);
render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>
);
await waitFor(() => expect(settled()).toBe(1));
await new Promise((resolve) => setTimeout(resolve, 50));
const cached = client.readQuery<{ item: { read: boolean } }>({ query: ITEM });
return { rendered: screen.getByLabelText('read').textContent, cached: cached?.item.read };
}
describe('optimistic write during result delivery', () => {
it('control: link completes in the same tick it emits', async () => {
const r = await run(true);
expect(r).toEqual({ rendered: 'true', cached: true });
});
it('bug: link completes one task after it emits', async () => {
const r = await run(false);
expect(r).toEqual({ rendered: 'true', cached: true });
});
it('variant: same timing, no optimisticResponse', async () => {
VARIANT.optimistic = false;
try {
const r = await run(false);
expect(r).toEqual({ rendered: 'true', cached: true });
} finally {
VARIANT.optimistic = true;
}
});
it('variant: same timing, cache-and-network', async () => {
VARIANT.fetchPolicy = 'cache-and-network';
try {
const r = await run(false);
expect(r).toEqual({ rendered: 'true', cached: true });
} finally {
VARIANT.fetchPolicy = 'cache-first';
}
});
});Observed on 4.2.10 and 4.3.0: { rendered: "false", cached: true } in the bug case.
The same thing happens in a real browser with a real HttpLink over fetch, with no simulated link timing. We checked this with a small Vite page: the same component, new HttpLink({ uri: "/graphql" }), and a local endpoint answering after 150ms. The bug case was stale on 5 of 5 reloads (the cache holds read: true, useQuery renders false). Both controls, with no optimisticResponse and with cache-and-network, were consistent. That page is the reproduction linked above.
@apollo/client version
4.3.0. It also reproduces on 4.2.10. The linked reproduction pins 4.3.0 and was verified from a clean npm install: the bug case was stale on 5 of 5 loads, and both controls were consistent. The same early return is on main.
Disclosure: this issue was drafted by Claude (Anthropic's AI assistant, via Claude Code) on behalf of @jasonbarnett667, who reviewed it and takes responsibility for it; follow-up questions will be answered by him directly.
Source: apollographql/apollo-client