Skip to content

SimulatePreloadedQuery fires spurious network request due to useBackgroundQuery racing with stream transport #546

@jsauca

Description

@jsauca

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions