How toAvoid Waterfalls in React Suspense
React’s Suspense is great for orchestrating asynchronous UI rendering, but it’s easy to accidentally introduce a “waterfall”, where one piece of async work waits unnecessarily for another to finish before starting.
In this tutorial, we’ll explore how sibling and nested Suspense boundaries behave, how this relates to JavaScript’s async patterns, and how to structure your data fetching to avoid delays.
Suspense Boundaries and Promise Patterns
Think of Suspense boundaries as async functions. The difference between sibling and nested boundaries maps closely to how you write your await
statements.
Nested Suspense Boundaries → Sequential await
When you nest Suspense boundaries, the inner boundary won’t start loading until the outer one finishes rendering. This is like writing:
let d1 = await getData1();
let d2 = await getData2();
Here, getData2()
doesn’t even start until getData1()
has resolved. That’s the waterfall problem.
Sibling Suspense Boundaries → Promise.all
If you place two Suspense boundaries as siblings, they can load in parallel, just like:
let [d1, d2] = await Promise.all([getData1(), getData2()]);
Both promises start immediately. React can even render the d1
UI while still waiting for d2
.
Nested Boundaries Without the Waterfall
Sometimes your UI is nested but your data is independent. You want nested boundaries for layout, but without sequential delays. In that case, you can “hide” the waterfall by starting the second request early:
let p2 = getData2();
let d1 = await getData1();
let d2 = await p2;
Now, getData2()
starts immediately, even though you await
it later.
Applying This in React Server Components (RSC)
Example Using React.cache
const getData2Cached = React.cache(getData2);
export default async function Page() {
// Start data2 early
getData2Cached();
let d1 = await getData1();
return (
<>
<UI1 data={d1} />
<Suspense fallback={<LoadingD2 />}>
<Child />
</Suspense>
</>
);
}
async function Child() {
let d2 = await getData2Cached();
return <UI2 data={d2} />;
}
Example Passing a Promise to a Client Component
You can also start the second request in a server component and pass the promise down to a client component. In the client component, you can use React.use
to unwrap it.
// Server Component
import ClientComponent from "./client-component";
export default async function Page() {
let p2 = getData2();
let d1 = await getData1();
return (
<>
<UI1 data={d1} />
<Suspense fallback={<LoadingD2 />}>
<ClientComponent promise={p2} />
</Suspense>
</>
);
}
// Client Component
"use client";
import { use } from "react";
interface Props {
promise: ReturnType<typeof getData2>;
}
export default function ClientComponent({ promise }: Props) {
let d2 = use(promise);
return <UI2 data={d2} />;
}
Using React Router Deferred Promises
React Router lets you start independent data fetching in parallel, even when some data is needed earlier.
import type { Route } from "./+types";
import { Suspense } from "react";
export async function loader() {
let p2 = getData2();
let d1 = await getData1();
return { d1, p2 };
}
export default function Component({ loaderData }: Route.ComponentProps) {
return (
<>
<UI1 data1={loaderData.d1} />
<Suspense fallback={<LoadingD2 />}>
{loaderData.p2.then((d2) => (
<UI2 data2={d2} />
))}
</Suspense>
</>
);
}
With this approach, getData2()
starts in the loader before getData1()
finishes, avoiding the waterfall while still rendering the parts of the UI that depend only on d1
first.