A dashboard that loads in 4 seconds on the office macbook and 12 on a Redmi 9A
A client's operations dashboard had 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 appeared instantly. On a mid-range Android over a flaky 4G connection, the summary appeared after 3 seconds, the approvals another 4 seconds later, and the chart staggered in at the 11-second mark.
The problem was not the API being slow. The problem was where the fetches were placed.
The layout looked like this: a parent `dashboard/layout.tsx` fetched the summary, and three child components each fetched their own data independently. The children were server components, so they rendered in parallel on the server — but each child waited for its parent layout's data to resolve before it could start its own fetch. The summary fetch blocked the approvals fetch blocked the chart fetch. Three sequential round trips where there should have been 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.
We restructured the dashboard so no parent fetched anything. Each panel became an independent async server component, and the page composed 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.
The dashboard now loads in 4 seconds on the Redmi 9A. Same API, same data, different placement.