All writing
6 min read

The Next.js Data Waterfall That's Quietly Killing Your TTFB

Awaiting fetches one after another in nested Server Components serializes your loads — here's how to parallelize without rewriting your tree.

Next.jsReactPerformance

Server Components made data fetching feel simple again: just await your data right where you render it. But that ergonomics hides a trap. When a parent component awaits before rendering a child that also awaits, the requests run in sequence — a waterfall — and your time-to-first-byte balloons.

Spotting the waterfall

Here is the innocent-looking version. The page awaits the user, then renders the dashboard, which awaits stats:

tsx
// page.tsx — sequential, slow
export default async function Page() {
  const user = await getUser();        // 200ms
  return <Dashboard userId={user.id} />;
}

async function Dashboard({ userId }: { userId: string }) {
  const stats = await getStats(userId); // 200ms
  return <Stats data={stats} />;
}

Those 200ms requests don't overlap — total is 400ms before a single byte ships. With three or four nested levels this is how a page that should feel instant ends up at a second of blank screen.

Fix 1: hoist independent fetches and run them together

If two requests don't depend on each other, start them at the same level and Promise.all them. Kick off the promises first, then await:

tsx
export default async function Page() {
  const userPromise = getUser();
  const feedPromise = getFeed();
  const [user, feed] = await Promise.all([userPromise, feedPromise]);
  return <Dashboard user={user} feed={feed} />;
}

Two 200ms calls now finish in ~200ms total. The rule of thumb: anything that doesn't need another request's result should never sit behind its await.

Fix 2: when fetches DO depend on each other, stream with Suspense

Sometimes getStats genuinely needs the user id first — you can't parallelize that. So don't block the whole page on it. Render the shell immediately and let the slow part stream in:

tsx
export default async function Page() {
  const user = await getUser(); // needed for the shell
  return (
    <>
      <Header user={user} />
      <Suspense fallback={<StatsSkeleton />}>
        <Stats userId={user.id} /> {/* awaits inside, streams later */}
      </Suspense>
    </>
  );
}

The header paints right away; the stats arrive when ready. The user perceives speed even though the dependent fetch still takes its time. Suspense converts a blocking wait into progressive rendering.

Fix 3: dedupe so you don't fetch the same thing twice

Once you split fetching across components, you'll call getUser() in two places. Wrap it in React's cache so a single request reuses the result within one render pass:

tsx
import { cache } from "react";

export const getUser = cache(async () => {
  const res = await fetch(`${API}/me`, { next: { revalidate: 60 } });
  return res.json();
});

Now call it freely anywhere in the tree — it hits the network once per request.

The takeaway

Server Component awaits are sequential by default. Parallelize independent data with Promise.all, stream dependent data behind Suspense, and dedupe with cache. None of this requires restructuring your component tree — it just stops your page from waiting on itself.


Written by Fady Ehab Amer

Get in touch →

Keep reading