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.
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:
// 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:
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:
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:
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
Theming Without a Build Step: How NYX Uses color-mix()
I built NYX, a zero-dependency component framework, so a whole theme can be retuned from a handful of custom properties — no Sass, no recompile, live in the browser.
What Building an OTP Input Taught Me About Controlled Components
Auto-advance, paste, and backspace look trivial until you build them — here are the controlled-input lessons that survive every form I ship.