PreloadQuery + useSuspenseQuery shows Suspense fallback in SSR HTML despite documentation stating it shouldn't
Environment:
-
@apollo/client:4.0.9 -
@apollo/client-integration-nextjs:0.14.1 -
next:16.0.1 -
react:19.2.0
Description:
When using the recommended PreloadQuery + useSuspenseQuery pattern for Next.js App Router, the Suspense fallback always appears in the initial server-rendered HTML, causing a visible flash during page load.
According to the official documentation:
"During initial render, the fallback should NOT appear because data preloading completes server-side before hydration."
And:
"The Suspense boundary here is optional and only for demonstration purposes."
However, in practice, the fallback is consistently rendered in the SSR HTML.
Expected Behavior:
The Suspense fallback should not appear in the initial HTML response. The preloaded data should be available immediately during hydration, preventing the Suspense boundary from activating.
Actual Behavior:
The Suspense fallback appears in the SSR HTML:
<!--$?--><template id="B:0"></template><div>Loading products...</div><!--/$-->
This causes a visible flash as the page loads, then hydrates with the actual data.
Code Example
Server Component (app/page.tsx):
import { PreloadQuery } from "@/lib/apollo-client";
import ProductsList from "@/components/products-list";
import { GET_PRODUCTS } from "@/lib/queries/products";
import { Suspense } from "react";
export default function Home() {
return (
<PreloadQuery query={GET_PRODUCTS}>
<Suspense fallback={<div>Loading products...</div>}>
<ProductsList />
</Suspense>
</PreloadQuery>
);
}
Client Component (components/products-list.tsx):
'use client';
import { useSuspenseQuery } from '@apollo/client/react';
import { GET_PRODUCTS, type Product } from '@/lib/queries/products';
export default function ProductsList() {
const { data } = useSuspenseQuery<{ products: Product[] }>(GET_PRODUCTS);
return (
<div>
<h1>Electrical Supply Products</h1>
<ul>
{data.products.map((product) => (
<li key={product.id}>
{product.name} - ${product.price.toFixed(2)}
</li>
))}
</ul>
</div>
);
}
Apollo Client Setup (lib/apollo-client.ts):
import { HttpLink } from "@apollo/client";
import {
registerApolloClient,
ApolloClient,
InMemoryCache,
} from "@apollo/client-integration-nextjs";
export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
return new ApolloClient({
cache: new InMemoryCache(),
link: new HttpLink({
uri: "http://localhost:8080/query",
}),
});
});
Client Wrapper (lib/apollo-wrapper.tsx):
"use client";
import { HttpLink } from "@apollo/client";
import {
ApolloNextAppProvider,
ApolloClient,
InMemoryCache,
} from "@apollo/client-integration-nextjs";
function makeClient() {
const httpLink = new HttpLink({
uri: "http://localhost:8080/query",
});
return new ApolloClient({
cache: new InMemoryCache(),
link: httpLink,
});
}
export function ApolloWrapper({ children }: { children: React.ReactNode }) {
return (
<ApolloNextAppProvider makeClient={makeClient}>
{children}
</ApolloNextAppProvider>
);
}
Attempted Workarounds
-
Importing the streaming module - Adding
import "@apollo/client-react-streaming";to preload the module (as suggested in #506) did not resolve the issue. -
Props-based approach works - Using direct
await getClient().query()in RSC and passing data as props eliminates the flash completely, but this bypasses the PreloadQuery pattern entirely.
This will be a dissatisfying answer :/
It is possible that React directly streams the final data. But in the end, Next.js and React decide.
- If you're identified as a search engine, Next.js will switch completely and just "hang" longer instead of displaying a suspense fallback.
- If you are prerendering static content with Next.js, you will probably also not see a suspense fallback.
- If you are dynamically server-side rendering, it's probably up to React to decide - and that will likely depend on the speed of your GraphQL server's response. It probably won't show a suspense fallback for a few milliseconds, but might do so for longer waiting times, as React always tries to get something on the screen fast, and even a fallback is better than nothing at all from React's perspective.
Either way: it's pretty much out of our hand. We do everything to enable situations where a suspense fallback isn't streamed, but we don't have the ability to finally enforce it.