Type something to search...
Fetching Data in Parallel vs Sequentially in Next.js: Avoiding Request Waterfalls

Fetching Data in Parallel vs Sequentially in Next.js: Avoiding Request Waterfalls

Server Components make data fetching feel almost too easy. You mark a component async, await whatever you need, and render. But that convenience hides a performance trap. Each await pauses the function until the promise resolves, so three independent requests written one after another take the sum of their durations instead of the longest one. Nest components that each await their own data and the delays stack up again, level by level.

This pattern is called a request waterfall, after the staircase shape it makes in a network timeline. It's one of the most common reasons a Next.js page feels slow even when every individual query is fast.

This post shows where waterfalls come from in the App Router, how to measure them, and the techniques for removing them: Promise.all, starting requests early, preloading, splitting work across Suspense boundaries, and passing promises to Client Components. It also covers the cases where sequential fetching is genuinely required and how to keep those from hurting.

What a Waterfall Costs

Say a dashboard needs four pieces of data:

RequestDuration
Current user120 ms
Recent orders300 ms
Notifications200 ms
Usage stats250 ms

Fetched one after another, the page waits 120 + 300 + 200 + 250 = 870 ms before it can render. Fetched at the same time, it waits for the slowest one: 300 ms. Same queries, same database, nearly three times faster.

The gap grows with every request you add, and with latency. If your server is far from your database or a third-party API, each round trip costs more, and a waterfall multiplies that cost.

A Small Helper for Measuring

Before fixing anything, measure it. This helper logs how long a promise takes:

// lib/timing.ts
export async function timed<T>(label: string, promise: Promise<T>): Promise<T> {
  const start = performance.now();
  try {
    return await promise;
  } finally {
    console.log(`${label}: ${Math.round(performance.now() - start)}ms`);
  }
}

And here's a data module that simulates the four requests with realistic delays, so the examples are runnable:

// lib/dashboard-data.ts
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export type User = { id: string; name: string; plan: "free" | "pro" };
export type Order = { id: string; total: number };
export type Notification = { id: string; text: string };
export type Usage = { apiCalls: number; storageMb: number };

export async function getUser(): Promise<User> {
  await wait(120);
  return { id: "u_1", name: "Ada", plan: "pro" };
}

export async function getOrders(userId: string): Promise<Order[]> {
  await wait(300);
  return [{ id: `o_${userId}_1`, total: 42 }];
}

export async function getNotifications(
  userId: string,
): Promise<Notification[]> {
  await wait(200);
  return [{ id: "n_1", text: `Welcome back, ${userId}` }];
}

export async function getUsage(userId: string): Promise<Usage> {
  await wait(250);
  return { apiCalls: 1820, storageMb: 512 };
}

In development you can also turn on logging.fetches in next.config.ts to see every fetch your Server Components make, with timings.

Waterfall Source 1: Sequential Awaits in One Component

This is the most common one:

// app/dashboard/page.tsx (slow version)
import {
  getNotifications,
  getOrders,
  getUsage,
  getUser,
} from "@/lib/dashboard-data";

export default async function DashboardPage() {
  const user = await getUser();
  const orders = await getOrders(user.id);
  const notifications = await getNotifications(user.id);
  const usage = await getUsage(user.id);

  return (
    <main>
      <h1>Hi, {user.name}</h1>
      <p>{orders.length} recent orders</p>
      <p>{notifications.length} notifications</p>
      <p>{usage.apiCalls} API calls this month</p>
    </main>
  );
}

Every line waits for the previous one. getOrders, getNotifications, and getUsage only need user.id; they don't depend on each other. There's no reason for them to run in sequence.

Fix: Promise.all

Start the independent requests together and wait for all of them:

// app/dashboard/page.tsx
import {
  getNotifications,
  getOrders,
  getUsage,
  getUser,
} from "@/lib/dashboard-data";

export default async function DashboardPage() {
  const user = await getUser();

  const [orders, notifications, usage] = await Promise.all([
    getOrders(user.id),
    getNotifications(user.id),
    getUsage(user.id),
  ]);

  return (
    <main>
      <h1>Hi, {user.name}</h1>
      <p>{orders.length} recent orders</p>
      <p>{notifications.length} notifications</p>
      <p>{usage.apiCalls} API calls this month</p>
    </main>
  );
}

The user request still has to come first, because the others need its ID. But the remaining three now overlap. Total time drops from 870 ms to 120 + 300 = 420 ms.

The important detail is that requests start when the function is called, not when it's awaited. Promise.all works because all three calls happen in the same expression before anything waits.

When some requests may fail

Promise.all rejects as soon as any promise rejects, which throws away the results that succeeded. If a failed notifications request shouldn't take down the whole dashboard, use Promise.allSettled:

const [ordersResult, notificationsResult, usageResult] =
  await Promise.allSettled([
    getOrders(user.id),
    getNotifications(user.id),
    getUsage(user.id),
  ]);

const notifications =
  notificationsResult.status === "fulfilled" ? notificationsResult.value : [];

Each result has a status of "fulfilled" or "rejected", so you decide per request how to degrade.

Waterfall Source 2: Starting Too Late

Sometimes requests are sequential not because of await order, but because one starts only after unrelated work finishes. Here, a permissions check delays the product request even though the product doesn't depend on it:

// Slow: the product fetch waits for the permission check
const canView = await checkPermission(userId, "catalog:view");
if (!canView) notFound();
const product = await getProduct(id);

Fix: start early, await late

Kick off the independent request first, do the blocking check, then await the result:

// app/products/[id]/page.tsx
import { notFound } from "next/navigation";
import { checkPermission, getCurrentUserId, getProduct } from "@/lib/catalog";

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const userId = await getCurrentUserId();

  const productPromise = getProduct(id); // starts now

  const canView = await checkPermission(userId, "catalog:view");
  if (!canView) notFound();

  const product = await productPromise; // likely already resolved
  if (!product) notFound();

  return <h1>{product.name}</h1>;
}

Here checkPermission, getCurrentUserId, and getProduct stand in for your own data layer. Both requests now run at the same time. The cost is that getProduct runs even when permission is denied. For a cheap read that's usually fine; for an expensive one, keep it sequential.

One caution: an unawaited promise that rejects before you await it can trigger an unhandled rejection warning in Node.js. If the early request might fail while you're still awaiting the other one, attach a handler or use Promise.all instead.

Waterfall Source 3: Nested Components

This one is harder to spot because it's spread across files:

// app/dashboard/page.tsx
export default async function DashboardPage() {
  const user = await getUser(); // 120 ms
  return <OrdersPanel userId={user.id} />;
}

// app/dashboard/orders-panel.tsx
async function OrdersPanel({ userId }: { userId: string }) {
  const orders = await getOrders(userId); // starts after the parent finishes
  return <OrderList orders={orders} />;
}

A child component can't start rendering until its parent returns JSX, and the parent can't return until its await resolves. Each level of nesting that awaits data adds another step to the staircase.

Colocating fetches with the components that use them is still the right default. Request memoization means two components calling the same getUser() share one request, so you don't need to fetch everything at the top and drill props. The goal is just to avoid long chains where each level waits for the one above.

Fix: preload

The preload pattern starts a request as early as possible and lets the component that needs it pick up the result later. It relies on React.cache, which memoizes a function's result for the duration of one server request:

// lib/orders.ts
import { cache } from "react";
import { getOrders as fetchOrders } from "@/lib/dashboard-data";

export const getOrders = cache(async (userId: string) => fetchOrders(userId));

export function preloadOrders(userId: string) {
  void getOrders(userId);
}
// app/dashboard/page.tsx
import { getUser } from "@/lib/dashboard-data";
import { preloadOrders } from "@/lib/orders";
import { OrdersPanel } from "./orders-panel";

export default async function DashboardPage() {
  const user = await getUser();
  preloadOrders(user.id); // start the request now

  // ...any other work here runs while orders load

  return <OrdersPanel userId={user.id} />;
}
// app/dashboard/orders-panel.tsx
import { getOrders } from "@/lib/orders";

export async function OrdersPanel({ userId }: { userId: string }) {
  const orders = await getOrders(userId); // reuses the in-flight request
  return <p>{orders.length} recent orders</p>;
}

When OrdersPanel calls getOrders(user.id), React.cache returns the promise that preloadOrders already started. The panel keeps its own data dependency, and the request starts as early as the parent can manage. React.cache is scoped to one request, so this is safe for per-user data.

Waterfall Source 4: One Slow Request Holding Up Everything

Even with Promise.all, a page can only render when its slowest request finishes. If usage stats take two seconds on a bad day, the whole dashboard waits two seconds.

Fix: split into sibling components behind Suspense

Move each independent piece of data into its own component and wrap each one in a Suspense boundary:

// app/dashboard/page.tsx
import { Suspense } from "react";
import {
  getNotifications,
  getOrders,
  getUsage,
  getUser,
} from "@/lib/dashboard-data";

export default async function DashboardPage() {
  const user = await getUser();

  return (
    <main>
      <h1>Hi, {user.name}</h1>

      <Suspense fallback={<p>Loading orders...</p>}>
        <Orders userId={user.id} />
      </Suspense>

      <Suspense fallback={<p>Loading notifications...</p>}>
        <Notifications userId={user.id} />
      </Suspense>

      <Suspense fallback={<p>Loading usage...</p>}>
        <Usage userId={user.id} />
      </Suspense>
    </main>
  );
}

async function Orders({ userId }: { userId: string }) {
  const orders = await getOrders(userId);
  return <p>{orders.length} recent orders</p>;
}

async function Notifications({ userId }: { userId: string }) {
  const notifications = await getNotifications(userId);
  return <p>{notifications.length} notifications</p>;
}

async function Usage({ userId }: { userId: string }) {
  const usage = await getUsage(userId);
  return <p>{usage.apiCalls} API calls this month</p>;
}

Sibling components under Suspense render concurrently, so the three requests run in parallel just like with Promise.all. The difference is what the user sees. The heading and fallbacks are sent right away, and each section streams in as soon as its own data is ready. A slow usage request no longer delays the orders.

Choose between the two approaches based on the UI:

ApproachUse when
Promise.all in one componentThe data is shown together and partial rendering would look odd
Sibling components with SuspenseSections are independent and can appear one by one

A loading.tsx file gives you a page-level Suspense boundary automatically, which is a good start. Granular boundaries around each slow section are better when sections load at different speeds.

Waterfall Source 5: Fetching in Client Components

The classic React waterfall happens in the browser: a component renders, a useEffect fires a request, the response renders a child, whose effect fires another request. Each step waits for JavaScript to load, hydrate, and run before the next request even starts.

The best fix is not to start the request in the browser at all. Start it in a Server Component and hand the promise to the Client Component, which reads it with React's use API:

// app/dashboard/activity/page.tsx
import { Suspense } from "react";
import { getNotifications, getUser } from "@/lib/dashboard-data";
import { NotificationList } from "./notification-list";

export default async function ActivityPage() {
  const user = await getUser();
  const notificationsPromise = getNotifications(user.id); // not awaited

  return (
    <Suspense fallback={<p>Loading notifications...</p>}>
      <NotificationList notificationsPromise={notificationsPromise} />
    </Suspense>
  );
}
// app/dashboard/activity/notification-list.tsx
"use client";

import { use, useState } from "react";
import type { Notification } from "@/lib/dashboard-data";

export function NotificationList({
  notificationsPromise,
}: {
  notificationsPromise: Promise<Notification[]>;
}) {
  const notifications = use(notificationsPromise);
  const [showAll, setShowAll] = useState(false);
  const visible = showAll ? notifications : notifications.slice(0, 3);

  return (
    <>
      <ul>
        {visible.map((n) => (
          <li key={n.id}>{n.text}</li>
        ))}
      </ul>
      <button onClick={() => setShowAll((v) => !v)}>
        {showAll ? "Show less" : "Show all"}
      </button>
    </>
  );
}

The request starts on the server while the page renders, the result streams to the browser, and the Client Component stays interactive. If you do need client-side fetching with a library like SWR or TanStack Query, the same principle applies: put independent queries in sibling components or use the library's parallel query helpers. The post on using SWR and TanStack Query alongside Server Components covers that in detail.

When Sequential Is Unavoidable

Some data really does depend on other data. You need the user before you can load their orders, or an artist before their albums. You can't parallelize a true dependency, but you can limit the damage:

  • Make the first request fast. It blocks everything after it. Select only the fields you need, add an index, or cache it if it changes rarely.
  • Stream the dependent part. Render what you can from the first result immediately and put the dependent component inside Suspense, so the page isn't blank while the second request runs.
  • Ask for a better endpoint. If you control the API, a single endpoint that returns both pieces saves a round trip. A database join is usually faster than two queries.
  • Look for hidden independence. Often only one field is needed from the first request. If you already have that value, from the URL or a session cookie, you can skip the dependency entirely.

Things That Look Like Parallelism but Aren't

Request memoization. Calling getUser() in five components produces one request, which is deduplication, not parallelism. It doesn't make different requests overlap.

Layouts and pages. Next.js does render a route's layouts and page in parallel, so a layout fetch and a page fetch overlap automatically. Within any single component, though, await order still decides everything.

Server Actions. The client dispatches Server Actions one at a time, so Promise.all over several actions still runs them in sequence. They're for mutations; fetch data for rendering in Server Components instead.

A Quick Checklist

  • Are there two or more await lines in a row whose calls don't use each other's results? Combine them with Promise.all.
  • Does a request wait for unrelated work? Start it first and await it later.
  • Does a child component fetch something its parent could have started? Preload it with React.cache.
  • Is one slow request delaying the whole page? Split sections into Suspense boundaries.
  • Is a Client Component fetching in useEffect? Start the request on the server and pass the promise.
  • Is the remaining sequential chain truly dependent? Make the first step fast and stream the rest.

Conclusion

Request waterfalls aren't caused by slow queries; they're caused by fast queries waiting in line. In Next.js Server Components, the line forms whenever an await holds up a request that didn't need to wait, whether that's two lines in the same function or two components nested in different files.

The fixes are straightforward once you see the pattern. Start independent requests together with Promise.all, start requests before unrelated blocking work, preload data for child components, and split independent sections into Suspense boundaries so they load and stream in parallel. Measure before and after; the difference is often the single biggest performance win available on a data-heavy page.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading