apollographql / apollographql/apollo-client-integrations
SimulatePreloadedQuery fires spurious network request due to useBackgroundQuery racing with stream transport
- Dominant language
- TypeScript
- Stars
- 556
- Forks
- 53
- PR merge metrics
- No merged PRs in 30d
Description
---
Package: @apollo/client-react-streaming v0.14.4 (via
@apollo/client-integration-nextjs v0.14.4)
Apollo Client: v4.1.6
---
Description
When using PreloadQuery from registerApolloClient with Next.js App
Router, the client always fires an additional network request after SSR
hydration; even though the server already fetched the data and
transported it via the RSC stream.
**Expected behavior**: The server fetches via PreloadQuery, the stream
transports the result to the client, and useSuspenseQuery (or
useReadQuery) reads from cache without a network request.
**Actual behavior:** A duplicate GraphQL request appears in the browser
Network tab on every page load.
---
Root cause
In SimulatePreloadedQuery.cc.js:
```
function SimulatePreloadedQuery({ queryRef, children }) {
const client = useApolloClient2();
reviveTransportedQueryRef(queryRef, client); // (1)
const bgQueryArgs = useMemo2(() => {
const { query, ...hydratedOptions } = deserializeOptions(
queryRef.$__apollo_queryRef.options
);
return [
query,
{ ...hydratedOptions, queryKey:
queryRef.$__apollo_queryRef.queryKey }
];
}, [queryRef.$__apollo_queryRef]);
useBackgroundQuery(...bgQueryArgs); // (2)
return children;
}
```
(1) reviveTransportedQueryRef creates a watchQuery observable that
reads from the transported ReadableStream via
ReadFromReadableStreamLink. This is async — the stream data arrives
over time as the RSC payload streams in.
(2) useBackgroundQuery runs synchronously after revival. It creates a
new, separate watchQuery observable with cache-first. At this point the
stream from (1) hasn't delivered data to the cache yet, so the cache
is empty. cache-first on an empty cache initiates a network request.
Additionally, useBackgroundQuery is called with queryKey:
queryRef.$__apollo_queryRef.queryKey (a UUID generated by
crypto.randomUUID()). This means:
- useBackgroundQuery creates a suspense cache entry keyed by [query,
variables, UUID]
- useSuspenseQuery in child components (without queryKey) looks up
[query, variables, undefined]
- These are different entries — so useSuspenseQuery also creates its
own watchQuery and fires yet another network request
The net result: 1 server fetch + 1-2 client fetches for the same query.
---
Reproduction
```
// page.tsx (React Server Component)
import { PreloadQuery } from "@/graphql/client"; // from
registerApolloClient
export default async function Page() {
return (
);
}
```
```
// ClientChild.tsx ("use client")
import { useSuspenseQuery } from "@apollo/client/react";
export function ClientChild() {
const { data } = useSuspenseQuery(MY_QUERY, {
variables: { id: "1" },
});
return
}
```
Open the page → observe the Network tab: MY_QUERY fires as a
client-side request after the SSR-rendered content is already visible.
---
Suggested fix
useBackgroundQuery inside SimulatePreloadedQuery should not create a
new watchQuery observable. Instead, it should reuse the
InternalQueryReference already created by reviveTransportedQueryRef —
which is backed by the stream-reading observable and won't fire a
network request.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in SimulatePreloadedQuery.cc.js and trace reviveTransportedQueryRef, useBackgroundQuery, and ReadFromReadableStreamLink to understand how the transported stream and query references are created. Reproduce the Next.js App Router example, then verify that the child useSuspenseQuery reads the transported result without creating duplicate network requests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- next.js, react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100