The waterfall problem in data fetching and how App Router addresses it

By Yuki Tanaka · 21 July 202677 views
The waterfall problem in data fetching and how App Router addresses it

A developer profiles a product detail page in her Next.js application. The page loads in 3.4 seconds. She expects it to be fast — it is a single page with a few data dependencies. She opens the Network tab and sees the problem: four sequential fetch calls. The first completes at 800ms, triggering the second, which completes at 1.4s, triggering the third, and so on. Each request waited for the previous to finish before starting.

This is the waterfall pattern. It is the default behavior of useEffect-based data fetching and of sequential await calls in server components. Eliminating it requires understanding where it comes from, what forms it takes across a component tree, and which tools the App Router provides to address each form.

How waterfalls form

The waterfall forms whenever data fetching is structured so that one request cannot start until another has returned. The most obvious form appears in sequential await calls:

// WATERFALL: Sequential awaits — 3 requests run one after the other
async function ProductPage({ productId }: { productId: string }) {
  const product = await fetchProduct(productId);        // ~400ms
  const seller = await fetchSeller(product.sellerId);  // ~400ms (waits for product)
  const reviews = await fetchReviews(productId);       // ~400ms (waits for seller)
  // Total: ~1200ms

  return <ProductView product={product} seller={seller} reviews={reviews} />;
}

The seller fetch waits for product to complete even though it only needs product.sellerId. The reviews fetch waits for seller to complete even though it does not depend on seller at all. The dependency graph does not require this ordering — the code structure imposes it unnecessarily.

The root cause is how JavaScript's await keyword works. Each await suspends execution until the promise resolves. Writing three sequential await statements creates three sequential suspensions, even when the underlying operations are unrelated. The interpreter does not infer independence — it executes the instructions in order.

In a client-rendered application using useEffect, the same pattern appears differently:

// WATERFALL in useEffect — nested fetches
function ProductPage({ productId }: { productId: string }) {
  const [product, setProduct] = useState(null);
  const [seller, setSeller] = useState(null);

  useEffect(() => {
    fetchProduct(productId).then(p => {
      setProduct(p);
      // Only starts after product is received
      fetchSeller(p.sellerId).then(setSeller);
    });
  }, [productId]);

  // ...
}

The fetch for seller is nested inside the .then() callback from fetchProduct. It cannot start until fetchProduct resolves, because sellerId is not available until then. This is a legitimate dependency — but the reviews fetch shares that same structural problem even though it has no dependency on seller.

Parallel fetching with Promise.all

When requests are independent, they can be initiated simultaneously:

// PARALLEL: All three start immediately — total time is the slowest, not the sum
async function ProductPage({ productId }: { productId: string }) {
  // Initiate all requests before awaiting any
  const productPromise = fetchProduct(productId);
  const reviewsPromise = fetchReviews(productId);

  // Wait for product to get sellerId, then start seller fetch in parallel with reviews
  const product = await productPromise;
  const [seller, reviews] = await Promise.all([
    fetchSeller(product.sellerId),
    reviewsPromise,
  ]);
  // Total: ~400ms (product) + ~400ms (seller/reviews in parallel) = ~800ms
  // vs. ~1200ms sequential

  return <ProductView product={product} seller={seller} reviews={reviews} />;
}

The key insight: calling fetchReviews(productId) does not require awaiting the result — it only requires initiating the request. The request starts at the beginning of the function. By the time product arrives and fetchSeller starts, fetchReviews is already partway through its execution.

For truly independent requests that share no data dependencies, all fetches can be initiated simultaneously:

async function DashboardPage({ userId }: { userId: string }) {
  // No dependency between these — run all in parallel
  const [orders, profile, notifications] = await Promise.all([
    fetchUserOrders(userId),
    fetchUserProfile(userId),
    fetchNotifications(userId),
  ]);
  // Total: max of the three durations, not their sum
  // If each takes 300ms, total is 300ms — not 900ms

  return <Dashboard orders={orders} profile={profile} notifications={notifications} />;
}

Promise.all rejects as soon as any promise rejects. For cases where some data is optional or failures should not block the page, use Promise.allSettled:

async function DashboardPage({ userId }: { userId: string }) {
  const [ordersResult, profileResult, notificationsResult] = await Promise.allSettled([
    fetchUserOrders(userId),
    fetchUserProfile(userId),
    fetchNotifications(userId),
  ]);

  const orders = ordersResult.status === 'fulfilled' ? ordersResult.value : [];
  const profile = profileResult.status === 'fulfilled' ? profileResult.value : null;
  const notifications = notificationsResult.status === 'fulfilled' ? notificationsResult.value : [];

  return <Dashboard orders={orders} profile={profile} notifications={notifications} />;
}

The component-level waterfall

The waterfall problem becomes more subtle when data fetching is distributed across a component tree. A parent fetches data, renders children, and the children fetch their own data — the children's fetches cannot start until the parent has rendered, creating a component-level waterfall:

// COMPONENT WATERFALL: Parent finishes before children can start fetching
async function OrderList({ userId }: { userId: string }) {
  const orders = await fetchOrders(userId);  // First fetch — ~400ms
  // Children cannot start until this completes and renders
  return orders.map(order => <OrderDetail key={order.id} orderId={order.id} />);
}

async function OrderDetail({ orderId }: { orderId: string }) {
  const detail = await fetchOrderDetail(orderId);  // Second fetch — waits for parent
  return <div>{detail.items.length} items</div>;
}

If fetchOrders returns 10 orders, and each fetchOrderDetail takes 200ms, the sequential execution looks like:

  • 400ms: fetchOrders completes, OrderList renders
  • 600ms: All 10 fetchOrderDetail calls start (they run in parallel since React renders all 10 OrderDetail components)
  • 800ms: All detail fetches complete

The waterfall here is between the list fetch and the detail fetches — not between individual detail fetches. In the Pages Router, this was unavoidable with component-level data fetching. The App Router provides a solution through request memoization.

App Router fetch memoization for eliminating component waterfalls

In the App Router, Server Components can prefetch data at a higher level in the tree. The fetch memoization system deduplicates identical requests within a single render pass:

// APP ROUTER: Prefetch all order details at the parent level
async function OrderList({ userId }: { userId: string }) {
  const orders = await fetchOrders(userId);

  // Start all detail fetches immediately — don't wait for rendering
  // These run in parallel and results are memoized
  await Promise.all(orders.map(order => fetchOrderDetail(order.id)));

  // Now rendering happens with data already in the memoization cache
  return orders.map(order => <OrderDetail key={order.id} orderId={order.id} />);
}

async function OrderDetail({ orderId }: { orderId: string }) {
  // This hits the memoization cache — no additional network request
  const detail = await fetchOrderDetail(orderId);
  return <div>{detail.items.length} items</div>;
}

The fetch memoization in the App Router deduplicates identical requests within a single render pass. Calling fetchOrderDetail(orderId) in the parent to prefetch, and then calling it again in OrderDetail, results in only one network request. The component tree can be written with data fetching co-located in each component, and the parent handles the parallelism.

For this to work correctly, the fetchOrderDetail function must use fetch() under the hood (or be wrapped to use the React cache function):

import { cache } from 'react';

// cache() memoizes the function per render — equivalent to fetch memoization
// for non-fetch data sources (databases, ORMs, etc.)
export const fetchOrderDetail = cache(async (orderId: string): Promise<OrderDetail> => {
  const detail = await db.orders.findUnique({ where: { id: orderId } });
  return detail;
});

The cache function from React works for any async function, not just fetch calls. Database queries, ORM calls, and SDK calls can all be memoized using it.

Streaming for partially-available data

When some data is fast and other data is slow, Suspense allows the fast data to render immediately while slow data loads:

// Fast data renders immediately; slow reviews stream in when ready
export default async function ProductPage({ params }: { params: { id: string } }) {
  // Fast: product info — render immediately (~100ms)
  const product = await fetchProduct(params.id);

  return (
    <div>
      <ProductInfo product={product} />

      {/* Reviews are slow — show skeleton, stream in when ready */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>

      {/* Recommendations are also slow — stream independently */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations productId={params.id} categoryId={product.categoryId} />
      </Suspense>
    </div>
  );
}

async function Reviews({ productId }: { productId: string }) {
  const reviews = await fetchReviews(productId);  // ~800ms — does not block initial paint
  return <ReviewList reviews={reviews} />;
}

async function Recommendations({ productId, categoryId }: {
  productId: string;
  categoryId: string;
}) {
  const recs = await fetchRecommendations(productId, categoryId);  // ~600ms
  return <RecommendationGrid recs={recs} />;
}

Reviews and Recommendations both start fetching at the same time — they are not sequential. The user sees ProductInfo immediately (within 100ms of the request), then Recommendations appear at ~600ms, then Reviews at ~800ms. No waterfall between them.

This is meaningfully different from traditional SSR where the server waits for all data before sending any HTML. Streaming sends partial HTML as each Suspense boundary resolves.

Loading the right data at the right level

A common mistake is fetching all data at the root of the page tree and passing it down as props. This works for small pages but creates problems as components grow:

// PROBLEMATIC: Root-level data fetching passed as props
export default async function OrdersPage({ params }: { params: { userId: string } }) {
  // All data fetched here, regardless of which component needs it
  const [orders, profile, settings, preferences] = await Promise.all([
    fetchOrders(params.userId),
    fetchProfile(params.userId),
    fetchSettings(params.userId),
    fetchPreferences(params.userId),
  ]);

  return (
    <div>
      <Header profile={profile} settings={settings} />
      <OrderList orders={orders} preferences={preferences} />
    </div>
  );
}

The App Router allows each component to own its data fetching, with deduplication handling the redundancy:

// BETTER: Co-located data fetching with deduplication
export default async function OrdersPage({ params }: { params: { userId: string } }) {
  return (
    <div>
      <Header userId={params.userId} />
      <Suspense fallback={<OrderListSkeleton />}>
        <OrderList userId={params.userId} />
      </Suspense>
    </div>
  );
}

async function Header({ userId }: { userId: string }) {
  // fetchProfile is memoized — if OrderList also calls it, only one request is made
  const profile = await fetchProfile(userId);
  const settings = await fetchSettings(userId);
  return <HeaderUI profile={profile} settings={settings} />;
}

async function OrderList({ userId }: { userId: string }) {
  const [orders, preferences] = await Promise.all([
    fetchOrders(userId),
    fetchPreferences(userId),
  ]);
  return <OrderListUI orders={orders} preferences={preferences} />;
}

Each component fetches exactly what it needs. Shared data (like profile) is fetched by any component that needs it and deduplicated at the cache level. This approach scales to large component trees without prop drilling or complex data orchestration at the root.

Identifying waterfalls in production

The Network panel in Chrome DevTools shows the timing of each fetch request. A waterfall is visible as a staircase pattern: each request starts after the previous one completes. The "Waterfall" column in the Network panel shows the time offset and duration of each request.

In Next.js, the --debug flag in development and the NEXT_DEBUG_FETCH=1 environment variable log fetch activity:

NEXT_DEBUG_FETCH=1 next dev

This outputs each fetch call with its URL, cache status, and duration. Sequential fetches with non-overlapping timestamps indicate a waterfall.

For production monitoring, custom instrumentation captures the pattern:

// lib/fetch-instrumented.ts
export async function fetchWithTiming(url: string, options?: RequestInit) {
  const start = performance.now();
  const result = await fetch(url, options);
  const duration = performance.now() - start;

  // Log or send to analytics
  if (process.env.NODE_ENV === 'production') {
    analytics.track('fetch', { url, duration });
  }

  return result;
}

Common mistakes when fixing waterfalls

Mistake 1: Using Promise.all where there are real dependencies. If fetchSeller genuinely requires product.sellerId, it cannot run before fetchProduct completes. Forcing them into a Promise.all with a placeholder value breaks correctness. Identify which dependencies are real before restructuring.

Mistake 2: Prefetching in a parent without memoization. Prefetching only helps if the child component calls the same memoized function. If the parent calls fetchDetailA(id) and the child calls fetchDetailB(id) (different function, different cache key), no deduplication occurs.

Mistake 3: Adding Suspense boundaries around every component. Suspense is a tool for displaying partial content, not for eliminating waterfalls. Multiple Suspense boundaries in a flat layout cause components to stream independently, which is good. But a deeply nested Suspense where the inner component cannot start fetching until the outer component resolves creates a new waterfall.

Mistake 4: Measuring waterfall elimination only in development. Development mode runs React in strict mode with double-render, has no build optimizations, and runs on a fast local network. Waterfall improvements must be measured against a production build, ideally from a simulated remote connection.

Mistake 5: Confusing parallel fetching with faster data. Promise.all reduces total time from the sum of durations to the max of durations. If a single upstream API call takes 800ms, Promise.all cannot make it faster — it can only prevent other requests from being blocked by it.

The fix for the original 3.4-second page load

The developer's 3.4-second page load was 4 sequential requests at ~800ms each. The requests were:

  1. Fetch product data (no dependencies)
  2. Fetch seller data (depends on product.sellerId)
  3. Fetch reviews (depends only on productId — same as available at the start)
  4. Fetch shipping options (depends only on product.categoryId)

Requests 1 and 3 had no dependency on each other. Request 2 depended on request 1. Request 4 depended on request 1.

The restructured version:

export default async function ProductPage({ params }: { params: { id: string } }) {
  // Start reviews fetch immediately — no dependency on product
  const reviewsPromise = fetchReviews(params.id);

  // Product must come first for seller and shipping
  const product = await fetchProduct(params.id);

  // Seller and shipping can now run in parallel
  const [seller, shipping] = await Promise.all([
    fetchSeller(product.sellerId),
    fetchShippingOptions(product.categoryId),
  ]);

  // Reviews were running in parallel — await their result
  const reviews = await reviewsPromise;

  return (
    <ProductView product={product} seller={seller} reviews={reviews} shipping={shipping} />
  );
}

Or, using streaming to show product information immediately while reviews and shipping load:

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetchProduct(params.id);

  return (
    <div>
      <ProductInfo product={product} />
      <Suspense fallback={<SellerSkeleton />}>
        <SellerInfo sellerId={product.sellerId} />
      </Suspense>
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>
      <Suspense fallback={<ShippingSkeleton />}>
        <ShippingOptions categoryId={product.categoryId} />
      </Suspense>
    </div>
  );
}

With streaming, the product information appears in ~200ms. SellerInfo, Reviews, and ShippingOptions all start fetching simultaneously. Each streams into the page as its data arrives. The measured time to first meaningful paint dropped from 3.4 seconds to under 1 second, with all secondary data visible within 2 seconds. The data dependencies were the same — only the ordering of when requests were initiated changed.

Forward-looking: React 19 and beyond

React 19's use() hook and improved streaming primitives make the patterns described here more composable. The use() hook allows reading a promise in any component, not just async server components, enabling patterns like:

// React 19 — pass a promise as a prop, read it in the child
export default function ProductPage({ params }: { params: { id: string } }) {
  const reviewsPromise = fetchReviews(params.id);  // Start immediately

  return (
    <Suspense fallback={<ReviewsSkeleton />}>
      <Reviews reviewsPromise={reviewsPromise} />
    </Suspense>
  );
}

function Reviews({ reviewsPromise }: { reviewsPromise: Promise<Review[]> }) {
  const reviews = use(reviewsPromise);  // Suspends until resolved
  return <ReviewList reviews={reviews} />;
}

This pattern starts the fetch at the parent level (eliminating the component-level waterfall) while keeping the data consumption co-located with the component that renders it. The App Router's fetch memoization and the cache() function from React provide similar outcomes today, but use() makes the intention more explicit. The waterfall problem is not going away — the tools for addressing it are simply becoming more expressive.

Comments

No comments yet. Be the first!

Sign in to leave a comment.