Ibrohim.dev
All posts
3 min read

Request waterfalls: how sequential awaits slow your page down

Why sequential awaits in Server Components slow a page down, and how to fix it with Promise.all and Suspense.

nextjsreactperformance

The page is slow, yet every request is fast on its own. Often the cause is not the requests themselves but their order: each one waits for the one before it. This is called a request waterfall.

What the problem looks like

dashboard/page.tsx
export default async function Dashboard() {
  const user = await getUser();     // 300 ms
  const orders = await getOrders(); // 400 ms
  const stats = await getStats();   // 500 ms
 
  return <DashboardView user={user} orders={orders} stats={stats} />;
}

The code is clean and easy to read. But every await pauses the next line, so the page waits 1200 ms. These three requests do not depend on each other, so they could have started together.

First fix: Promise.all

dashboard/page.tsx
export default async function Dashboard() {
  const [user, orders, stats] = await Promise.all([
    getUser(),
    getOrders(),
    getStats(),
  ]);
 
  return <DashboardView user={user} orders={orders} stats={stats} />;
}

Now all three requests go out at once and the page waits only for the slowest one, 500 ms.

Two things to keep in mind. If one request fails, Promise.all rejects as a whole; use Promise.allSettled when partial results are acceptable. And the user still sees nothing until the slowest request finishes.

Second fix: split with Suspense

When each section fetches its own data, the sections run in parallel and each one appears as soon as it is ready:

dashboard/page.tsx
import { Suspense } from "react";
 
export default function Dashboard() {
  return (
    <>
      <Suspense fallback={<HeaderSkeleton />}>
        <UserHeader />
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <Orders />
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>
    </>
  );
}
 
async function Orders() {
  const orders = await getOrders();
  return <OrderList orders={orders} />;
}

The header shows at 300 ms, the orders at 400 ms, the stats at 500 ms. The total time is the same, but the user is no longer staring at an empty screen.

When requests really do depend on each other

Sometimes the order is required: you need the user ID before you can fetch the orders. In that case, start the independent requests early:

const statsPromise = getStats(); // started, not awaited yet
 
const user = await getUser();
const orders = await getOrders(user.id);
const stats = await statsPromise;

If several components need the same data, wrap the function in React's cache. The request then runs only once per render:

lib/user.ts
import { cache } from "react";
 
export const getUser = cache(async () => {
  return db.user.current();
});

How to spot one

  • Look for several awaits in a row in one function and check whether each one actually needs the previous result.
  • A parent that awaits and then renders a child that also awaits is a waterfall too: the child cannot start until the parent finishes.
  • Log how long each request takes. If the sum equals the page time, they are running one after another.

Takeaway

Making a request faster is hard; changing the order is easy. Run independent requests in parallel first, then move the slow sections behind Suspense. In many cases that alone makes the page feel several times faster.