←Back to blog
Article

Data fetching in App Router and the waterfalls it hides

Data fetching in App Router and the waterfalls it hides
S

StriveBit

4 min readWeb Applications

A dashboard that loads in 4 seconds on a MacBook and 12 on a Redmi 9A

Take an operations dashboard with three panels: a summary of today's orders, a list of pending approvals, and a chart of weekly throughput. On the dev machine, all three appear instantly. On a mid-range Android over a flaky 4G connection, the summary appears after 3 seconds, the approvals another 4 seconds later, and the chart staggers in around the 11-second mark. We have seen this shape often enough that it is worth walking through.

The problem is not the API being slow. The problem is where the fetches are placed.

The layout looks like this: a parent `dashboard/layout.tsx` fetches the summary, and three child components each fetch their own data independently. The children are server components, so they render in parallel on the server — but each child waits for its parent layout's data to resolve before it can start its own fetch. The summary fetch blocks the approvals fetch blocks the chart fetch. Three sequential round trips where there should be one.

This is the App Router waterfall, and it is easy to miss because the dev machine hides it.

Where the waterfall starts

In the Pages Router, `getServerSideProps` in a page and `getInitialProps` in components above it ran in parallel. The App Router inverts this: a layout's `async` function resolves before its children begin rendering. If a child is also an async server component that fetches data, that fetch waits for the parent.

The fix is usually to move the fetch down, not up. If the summary does not need to be present before the approvals or chart can render, it should not be in a layout that wraps them. Put each fetch in the leaf component that owns that data.

The restructure is to have no parent fetch anything. Each panel becomes an independent async server component, and the page composes them as siblings:

// app/dashboard/page.tsx
import { Summary } from './summary'
import { Approvals } from './approvals'
import { ThroughputChart } from './chart'

export default function DashboardPage() {
  return (
    <div className="grid gap-4">
      <Summary />
      <Approvals />
      <ThroughputChart />
    </div>
  )
}
// app/dashboard/summary.tsx
import { db } from '@/lib/db'

export async function Summary() {
  const orders = await db.order.countToday()
  return <SummaryCard count={orders} />
}

All three fetches now start in the same tick on the server. The slowest panel determines total load time, not the sum of all three.

When the layout should fetch

There is one case where fetching in a layout is correct: when the child genuinely cannot render without the parent's data. A sidebar that shows the current user's name, and every page below it uses that user's permissions to decide what to show — that data belongs in the layout. The waterfall is intentional, because the child fetch is parameterized by the parent's result.

If the child's fetch depends on a value from the parent, you have a real waterfall and the only question is whether you can flatten it. Sometimes you can pass an ID down and let the child fetch by that ID in parallel with the parent fetching its own data. Sometimes the dependency is real and sequential, and the best you can do is make each fetch fast.

The client component trap

A different waterfall appears when developers mark a panel `"use client"` and fetch in `useEffect`. Now the server sends HTML for the shell, the client hydrates, the effect fires, and the fetch begins — one full round trip after the page is already interactive. On a slow phone, that adds 2-3 seconds of staring at a skeleton.

If the data is needed for first paint, keep the component on the server. Use `use client` only for interactivity that genuinely needs it — a filter dropdown, a sortable table — and fetch the data those components need from a server component parent that passes it as props.

Flatten the waterfall and you should expect that dashboard to load in roughly a third of the time on the same phone — the slowest panel's fetch, not the sum of all three. Same API, same data, different placement.

←Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch