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 (
<PreloadQuery query={MY_QUERY} variables={{ id: "1" }}>
<ClientChild />
</PreloadQuery>
);
}
// ClientChild.tsx ("use client")
import { useSuspenseQuery } from "@apollo/client/react";
export function ClientChild() {
const { data } = useSuspenseQuery(MY_QUERY, {
variables: { id: "1" },
});
return <div>{data.name}</div>;
}
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.
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:
(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:
variables, UUID]
[query, variables, undefined]
own watchQuery and fires yet another network request
The net result: 1 server fetch + 1-2 client fetches for the same query.
Reproduction
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.