DEV Community

Nainik Mehta
Nainik Mehta

Posted on

Avoid GraphQL waterfalls in Next.js App Router with Suspense

Summary

GraphQL waterfalls—where multiple queries run serially on the server and block rendering—are a common source of slow LCP and sluggish perceived performance in App Router apps. With React Server Components (RSC) and the App Router's streaming model, you can turn those slow waterfalls into streamed UI islands that appear as cards or regions resolve.

This article explains concrete, production-ready patterns for Next.js App Router GraphQL streaming: how to kick off fetches, split independent requests, use Suspense boundaries effectively, and handle @defer payloads and cache revalidation. Expect a pragmatic code example and tips that saved ~1.7s of LCP in a dashboard I worked on.

Why waterfalls happen with RSC + GraphQL

React Server Components and the App Router stream HTML as Suspense boundaries resolve. But if your GraphQL calls are chained inside a single tree (one await after another), the server renders in a waterfall: the first query must finish before the next starts, and the browser waits for larger parts of the Flight payload.

Result: high TTFB and delayed LCP even though parts of the UI could have streamed earlier.

The pattern that fixes it (high level)

  • Kick off fetches early in Server Components (preload the request work) and pass lightweight query refs down to client components.
  • Split independent data needs into sibling Server Components so the server executes them in parallel.
  • Wrap each region in its own Suspense boundary (islands, not one global Suspense).
  • Use persisted queries + revalidate tags (or Next.js fetch cache-control) to keep server revalidation cheap.
  • For @defer fragments, use SSRMultipartLink (or equivalent) so deferred payloads don't block the initial SSR pass.
  • Protect every async Server Component with an ErrorBoundary so a thrown error doesn’t break the Flight stream and produce a blank viewport.

Concrete example (Apollo + PreloadQuery + Suspense)

Below is a compact pattern using Apollo's PreloadQuery idea. The Server Component starts the query early and passes a queryRef; the client or deeper components read from it without re-requesting.

// app/dashboard/page.tsx (Server Component)
import { Suspense } from 'react';
import { preloadQuery } from '@/lib/apollo'; // register/adapter for your app
import { USER_STATS_QUERY, ORDERS_QUERY, NOTIFS_QUERY } from '@/graphql/queries';
import UserStats from './UserStats';
import OrdersCard from './OrdersCard';
import NotifsCard from './NotifsCard';

export default async function DashboardPage({ params }) {
  // kick off three independent queries early
  const statsRef = preloadQuery(USER_STATS_QUERY, { id: params.id });
  const ordersRef = preloadQuery(ORDERS_QUERY, { id: params.id });
  const notifsRef = preloadQuery(NOTIFS_QUERY, { id: params.id });

  return (
    <main>
      <h1>Dashboard</h1>

      <Suspense fallback={<CardSkeleton />}>
        <UserStats queryRef={statsRef} />
      </Suspense>

      <Suspense fallback={<CardSkeleton />}>
        <OrdersCard queryRef={ordersRef} />
      </Suspense>

      <Suspense fallback={<CardSkeleton />}>
        <NotifsCard queryRef={notifsRef} />
      </Suspense>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Each sibling component receives a queryRef so the server's work can begin immediately and each query can resolve independently. Next.js can stream each resolved card as it becomes available.

Why this is better

  • Parallel execution: Because the queries are started independently, the server invokes them in parallel rather than serially awaiting one another.
  • Streaming: Each Suspense boundary unlocks a chunk of HTML as it resolves; the browser shows the first cards quickly.
  • Hydration-friendly: The queryRef pattern avoids refetching and hydrates client-side cache cleanly.

Handling @defer and multipart responses

If your GraphQL endpoint uses @defer/@stream, the server may produce multipart responses. For Apollo-based stacks, the SSRMultipartLink (from Apollo's Next.js integration package) helps:

  • stripDefer: optionally remove @defer for initial render
  • cutoffDelay: accumulate quick incremental parts so the initial HTML isn't waiting too long

Using SSRMultipartLink lets the initial HTML be sent right away while incremental fragments stream in.

Cache, persisted queries, and revalidation

Persisted queries (sending a query hash instead of full text) reduces request size and server parsing overhead. Combine this with Next.js revalidation tags or Http fetch options:

  • Use cache tags (revalidateTag) for on-demand invalidation from Server Actions
  • Or use fetch(..., { next: { revalidate: 60 } }) for time-based ISR

Caveat: revalidateTag and Server Component revalidation currently operate at the route level; revalidating a tag can trigger regeneration of the entire route in some Next.js versions. Avoid memoization that blocks updates on components whose data you expect to revalidate — memo can cause unexpected full page reloads on tag revalidation.

Error boundaries and Flight stream safety

If a Server Component throws during rendering, the Flight stream can abort and leave a blank viewport. Always protect async Server Components with an ErrorBoundary (server-side error.tsx and client-side boundaries for interactive subtrees).

Practical Suspense rules of thumb

  • Break a page into meaningful islands — aim for 3 or fewer nesting tiers.
  • Don’t wrap the whole page in one Suspense; it defeats streaming. Instead wrap distinct UI regions (cards, panels) so they can stream independently.
  • Fewer but meaningful boundaries produce smaller Flight payloads and less client reconciliation.

Measuring success: what to look for

Measure LCP and TTFB before/after. In one dashboard where three queries were serialized, applying the sibling + Suspense + persisted queries pattern cut LCP from ~2.8s to ~1.1s and improved TTFB by ~400–500ms. More importantly: the page felt usable immediately because the first cards streamed in while heavier queries finished.

Common pitfalls

  • Revalidation granularity: revalidateTag may re-render the whole route; test and plan around that behavior.
  • Memoization: overly aggressive memoization of layouts/components can interfere with Next.js revalidation flow and force full reloads.
  • Mixing server and client data ownership: keep server-only data in Server Components and use preload/queryRef patterns to hand off to client components.
  • Not handling multipart: if you rely on @defer, test with SSRMultipartLink locally and verify the multipart boundaries.

Closing advice

If you’re using Next.js App Router GraphQL streaming, these patterns will help you convert long server waterfalls into streamed, usable UIs. Start by identifying long serialized query paths with your profiler or a network trace, then:

  1. Preload independent queries in Server Components.
  2. Split them into siblings and wrap each with Suspense.
  3. Use persisted queries + cache/revalidate strategies.
  4. Add SSRMultipartLink for @defer and ErrorBoundaries for safety.

If you share the biggest GraphQL waterfall you’ve eliminated and the win in LCP or perceived speed, I’d love to compare notes.

Top comments (0)