Skip to content
Chapter 94Lesson 6

RSC waterfalls and Promise.all

Fix the data-fetching waterfalls that slow Server Components with Promise.all, React cache, and Suspense streaming.

A dashboard loads sluggishly, slow enough to notice but not broken. You open the trace: the server render took about 320ms. You check the database first, where slowness usually hides, but every query runs under 80ms. Every operation is fast, and the page is still slow. Where did the time go?

The component awaited four reads one after another when three of them had no reason to wait. You already know the fix from Parallel by default: run independent work in parallel and go sequential only when a real dependency forces it. This lesson puts that rule to work inside a Server Component. You have seen the same bug one layer down: the N+1 query from Spotting N+1 is its match at the database layer.

The fastest way to spot the bug is on a time axis.

Picture an RSC that awaits four reads, each about 80ms. It awaits user, then org (which needs user.orgId), then invoices and team (which both need org.id). One after another, that is 320ms.

But only some of those waits are real. org depends on user, and invoices and team both depend on org. Neither sibling depends on the other, so they can run together. The floor for this page is user → org → max(invoices, team), about 240ms. That is a quarter of the time recovered without touching a query.

user
80ms
org
80ms
invoices
80ms
team
80ms
~320ms
0
340ms
The waterfall: four awaits, each starting where the last ended, a descending staircase totaling ~320ms.
user
80ms
org
80ms
invoices
80ms
team
80ms
same dependency (org), independent of each other
~320ms
0
340ms

invoices and team both wait on org, but not on each other, so nothing forces them to run in sequence.

saved
user
80ms
org
80ms
invoices
80ms
team
80ms
~240ms
0
340ms

Run the siblings together: they leave as soon as org resolves, turning three serial waits into two. The page now takes as long as the slower sibling, about 240ms; the green span is the ~80ms reclaimed.

The reading skill is spatial: bars that stack into a staircase run in series; bars that overlap run in parallel.

This bug hides when you look at one query at a time.

Go back to the engineer from the opening. They suspected the database and profiled each query in isolation: getUser, getOrg, listInvoices, and listTeamMembers were all fast. Every part is healthy, so they declare the database fine and leave the page slow. The cost is not in any one read but in the order, which is invisible until you see the reads on one timeline, relative to each other.

That timeline is a trace , and the tracing you wired with Sentry capture records it. That lesson left tracesSampleRate at 0 and deferred raising it to this performance chapter; turn it up now and the dashboard request records the spans you need. Each span is one operation with a start time and a duration, stacked on a shared time axis, and you read it like the timing diagram: stacked without overlapping means series, overlapping means parallel.

The diagnosis is mechanical. Open the slow page’s trace, find a run of spans stacked into a descending staircase, and for each adjacent pair ask: does the second span need the result of the first? If yes, the wait is real; leave it. If no, you have found a waterfall to fix.

GET /dashboard
320ms
getUser
80ms
getOrg
80ms
listInvoices
80ms
listTeamMembers
80ms
each bar starts where the last ended — sequential
0
320ms
The dashboard's server trace. Each child span starts where the previous one ended, and that descending diagonal is the waterfall.

Now read one yourself. The trace below has five spans, some a genuine dependency chain and some needlessly serial.

Here is the trace for GET /settings. Each bar starts where the one above it ended — a five-step staircase. The note in parentheses is what each read needs before it can start.

getUser ████ 0–60ms
getOrg ████ 60–130ms (needs user.orgId)
listProjects ████ 130–210ms (needs org.id)
listMembers ████ 210–290ms (needs org.id)
getBillingSummary ██ 290–360ms (needs org.id)

This page currently takes ~360ms. Which of these reads belong in one parallel group — bars that have no reason to stack and could all leave together? Select every read that belongs in that group.

getUser
getOrg
listProjects
listMembers
getBillingSummary

Now for the fixes.

Parallelizing independent awaits with Promise.all

Section titled “Parallelizing independent awaits with Promise.all”

The simplest and most common waterfall is the one from the opening: a single component body with awaits stacked in a row.

Before adding a second await in a component body, ask whether the read needs the value you just awaited. If not, the two should run together with Promise.all. If so, keep them sequential: the second read cannot start without the first.

Apply this to the dashboard. org needs user, so that await stays. But invoices and team need only org, not each other, so they fire together once org resolves.

export default async function DashboardPage() {
const { orgId } = await requireOrgUser();
const org = await getOrganization(orgId);
const invoices = await listInvoices(org.id, org.billingPeriod);
const team = await listTeamMembers(org.id);
return <Dashboard org={org} invoices={invoices} team={team} />;
}

Four round trips in series. org waits on auth and invoices needs the billing period, but team is stuck behind invoices for no reason. About 320ms.

Three serial waits became two: user → org → max(invoices, team). That 80ms is paid on every request, so under load it is 80ms times your traffic, each one holding a thread the whole time. The rewrite costs one line.

People get Promise.all wrong in two silent ways.

The first is failure. Promise.all rejects the moment any promise rejects, handing you that first rejection while the others run on, their results discarded. For a page that needs everything or nothing, that is right: if one read fails the render is dead anyway. But when you would rather render what succeeded and degrade the rest, reach for Promise.allSettled (from Promise combinators), which waits for every promise and reports each outcome separately.

The second mistake never throws, which makes it the dangerous one. Wrap two reads in Promise.all when the second needed the first’s value, and the second runs with undefined and quietly produces wrong data: no error, no crash, just a page built on bad values. The dependency check is the only thing standing between you and that bug.

Now do the rewrite yourself. The reads below are timed async functions: one has a real dependency and must stay sequential, the other two are independent. Turn the independent pair into a single Promise.all and watch the total time drop.

getOrg must run first — it returns the org the other two reads need. But listInvoices and listMembers only depend on the org, not on each other, so they should leave together. Rewrite loadDashboard so the two independent reads run in parallel with Promise.all while getOrg stays sequential. The starter takes ~240ms; the rewrite should bring it to ~160ms — and dropping getOrg into the same Promise.all breaks the data the reads need.

    Reveal solution
    export const loadDashboard = async () => {
    const org = await getOrg();
    const [invoices, members] = await Promise.all([
    listInvoices(org.id),
    listMembers(org.id),
    ]);
    return { org, invoices, members };
    };

    getOrg stays its own await because both reads need org.id, a real dependency. Once it resolves, listInvoices and listMembers leave together inside one Promise.all, since neither needs the other’s result. Three serial waits become two: org → max(invoices, members), about 160ms. Folding getOrg into the same Promise.all would force org.id to be read before it exists, and the dependency check is what stops you.

    When the waterfall hides in the component tree

    Section titled “When the waterfall hides in the component tree”

    The waterfall inside a single function body is the easy one: every await is right there in front of you. The harder one, behind most real cases, never shows up in any single function. It comes from the shape of your component tree.

    A parent Server Component awaits its read and renders. A child Server Component awaits its own read, which uses nothing the parent fetched. Yet the child’s read cannot start until the parent finishes rendering, because rendering is sequential: the server renders the parent, reaches the child in the output, and only then runs the child’s body. The two reads serialize on render order, not on data. It is easy to miss, because the code looks clean: each component fetches its own data, co-located, no prop-drilling, and it waterfalls anyway.

    In the trace below, the parent DashboardPage awaits the org and its child InvoiceList awaits the invoices, so the child’s read sits idle until the parent’s resolves.

    Render order is fetch order

    This waterfall is structural: it is what naive nesting does, not a typo. You have three ways out.

    Option one: hoist the fetch up. Move both reads into the parent, fire them (with Promise.all if they are independent), and pass the results down as props. Both reads now run at the top together, so the timing is fixed. The cost: the parent must know about data its children consume, and in a deep tree that means prop-drilling a value through components that do not care about it.

    Option two: React cache(). This fixes the timing without giving up co-location. Wrap the read function in cache(), which deduplicates it within a single render: call it five times in one pass and it runs once, handing every caller the same in-flight promise. The parent can now start the read by calling it without awaiting, so the request is already in flight when the child runs await listInvoices(orgId) and receives that same promise instead of starting a fresh one. The child stays self-contained, the timing goes parallel, and nobody drills a prop.

    When you read with fetch(), Next.js deduplicates identical GET requests within a render pass for free. Drizzle does not: db.query.invoices.findMany(...) called twice in one render runs twice, because nothing memoizes it. This app reads through Drizzle, so you wrap the query in cache() yourself to get the dedup and the kick-off pattern.

    export const listInvoices = cache(async (orgId: string) => {
    return db.query.invoices.findMany({ where: eq(invoices.organizationId, orgId) });
    });
    export default async function DashboardPage({ orgId }: { orgId: string }) {
    listInvoices(orgId);
    return (
    <section>
    <OrgHeader orgId={orgId} />
    <InvoiceList orgId={orgId} />
    </section>
    );
    }
    async function InvoiceList({ orgId }: { orgId: string }) {
    const invoices = await listInvoices(orgId);
    return <InvoiceTable rows={invoices} />;
    }

    cache() makes the Drizzle read request-scoped: called many times in one render, it runs once and shares the result. Drizzle reads aren’t auto-deduped, so this wrap unlocks the pattern.

    export const listInvoices = cache(async (orgId: string) => {
    return db.query.invoices.findMany({ where: eq(invoices.organizationId, orgId) });
    });
    export default async function DashboardPage({ orgId }: { orgId: string }) {
    listInvoices(orgId);
    return (
    <section>
    <OrgHeader orgId={orgId} />
    <InvoiceList orgId={orgId} />
    </section>
    );
    }
    async function InvoiceList({ orgId }: { orgId: string }) {
    const invoices = await listInvoices(orgId);
    return <InvoiceTable rows={invoices} />;
    }

    The parent starts the read without awaiting it, warming the cache so the promise is in flight while the rest of the tree renders. It is not a forgotten await.

    export const listInvoices = cache(async (orgId: string) => {
    return db.query.invoices.findMany({ where: eq(invoices.organizationId, orgId) });
    });
    export default async function DashboardPage({ orgId }: { orgId: string }) {
    listInvoices(orgId);
    return (
    <section>
    <OrgHeader orgId={orgId} />
    <InvoiceList orgId={orgId} />
    </section>
    );
    }
    async function InvoiceList({ orgId }: { orgId: string }) {
    const invoices = await listInvoices(orgId);
    return <InvoiceTable rows={invoices} />;
    }

    The child awaits its own read, co-located and self-contained, but receives the in-flight promise the parent started, so it doesn’t pay a second round trip.

    1 / 1

    Option three: sibling Suspense boundaries. Split the children so each fetches under its own <Suspense>. Siblings under separate boundaries fetch in parallel and stream in independently, covered in depth next.

    So far the goal has been to overlap independent reads. But on some slow pages, overlapping is not the lever.

    Picture a dashboard with two reads: an analytics aggregation that takes ~800ms and a user profile that takes ~50ms. Even with Promise.all parallelizing them perfectly, the page cannot paint until the slower one resolves, so the user stares at a blank screen for 800ms. The reads are already parallel, so serialization is not the problem; the slow read blocks first paint for everything else.

    The fix is to stop making the fast content wait. Wrap the slow region in <Suspense fallback={...}>: the fast content paints immediately, a skeleton holds the slow region’s place, and the slow content streams in when ready.

    One precise point, because it is the common misconception: Suspense is not a speed-up. The slow fetch is exactly as slow; the 800ms does not change. What changes is when the user sees something, with first paint moving from 800ms to 50ms. The mechanics of streaming belong to the App Router unit; here you need the shape and the decision.

    You now have two “don’t block” shapes and must pick between them:

    • Parallel-await when all the data must be present before the page is worth showing. A transactional page, such as an invoice you are about to approve and pay, should not paint half-formed: show it complete or show a loader. Use Promise.all.
    • Suspense streaming when partial paint is useful. A dashboard of independent widgets has no reason to hold the fast ones hostage to the slow one. Stream the slow region.

    Both start from the dependency graph; streaming only changes what you do with an independent slow read once you have found it.

    first paint ~800ms
    profile
    50ms
    analytics
    800ms
    0
    900ms
    Block on all (Promise.all): both reads run in parallel, but the page can't paint until the slower one finishes, so first paint sits at the end of analytics — a blank screen for ~800ms.
    first paint ~50ms
    profile
    50ms
    analytics
    skeleton — streaming in…
    0
    900ms

    Stream the slow one: wrap analytics in <Suspense>, the fast profile paints immediately, and a skeleton holds the slow region. First paint jumps to ~50ms. The analytics bar hasn’t moved — the fetch is still 800ms; only the first-paint line did.

    Now make the call yourself. Walk the decision below through a few scenarios and watch which shape it lands on.

    Which fix does this page need?

    Serialization versus duplication, parallelism versus caching

    Section titled “Serialization versus duplication, parallelism versus caching”

    These two problems look alike but have different fixes, and conflating them is the common mistake.

    Serialization is independent reads running one after another. It wastes time waiting, and you fix it with parallelism: Promise.all or streaming.

    Duplication is the same read happening many times in one render: getUser called in the layout, again in the header, again in a sidebar widget. It wastes time repeating, and you fix it with caching.

    A single page can suffer both. Once you know which you are facing, two caching tools apply, and choosing between them is its own common mistake:

    • React cache() is request-scoped memoization: the same read called N times in one render runs once, then is forgotten when the request ends.
    • The 'use cache' directive is cross-request persistence: the result survives between requests, so the next visitor reuses it.

    The decision rule is the scope of the duplication. If the same read fires several times in this render, use cache(). If every visitor re-runs the same expensive read, use 'use cache'. Pick the wrong one and it either does nothing or caches something it should not.

    This component-tree waterfall is the N+1 query from Spotting N+1 one layer up: each row awaits its own read, and the database sees N serial queries instead of one. The fix is the same shape: hoist the fetch, batch it into one query, then pass the rows down.

    Once a week, open one slow trace, look for the diagonal staircase, and run the dependency check on it.