Parallel LLM Requests in React Server Components: Eliminating AI Fetch Waterfalls
Calling multiple LLM endpoints sequentially multiplies your latency. React Server Components make parallelising them almost free — here's the pattern, when to use Promise.allSettled, and how Suspense gives you incremental streaming on top.
5 min read
Most AI-native pages call more than one LLM endpoint per render: a summary here, a classification there, a recommended-next-action alongside it. The temptation is to write them sequentially — one await after the next — because it reads naturally. The cost is real: if each call takes 800 ms, three sequential calls hold the response for 2.4 seconds before the user sees anything.
React Server Components eliminate that cost almost for free, because async functions are composable and the fix is a single pattern.
How the waterfall happens
In a Server Component, every await blocks the one below it. This is the naive version:
export default async function ReportPage({ params }) {
const { id } = await params
// Each call waits for the previous one — sequential, ~2.4 s total
const summary = await summarise(id)
const sentiment = await classifySentiment(id)
const nextSteps = await suggestNextSteps(id)
return <Report summary={summary} sentiment={sentiment} nextSteps={nextSteps} />
}Three independent LLM calls, each blocked on the one before it. None of them need each other's result — they just happen to be written in sequence.
The fix: initiate before you await
The rule is simple: call the async function first, collect the promise, then await all of them together with Promise.all. The requests start the moment you call the function, not the moment you await it.
export default async function ReportPage({ params }) {
const { id } = await params
// All three requests start immediately — parallel, ~800 ms total
const summaryP = summarise(id)
const sentimentP = classifySentiment(id)
const nextStepsP = suggestNextSteps(id)
const [summary, sentiment, nextSteps] = await Promise.all([
summaryP,
sentimentP,
nextStepsP,
])
return <Report summary={summary} sentiment={sentiment} nextSteps={nextSteps} />
}Wall-clock time drops to the slowest single call, not the sum of all calls. For three 800 ms requests that's 800 ms instead of 2.4 seconds.
Partial failure with Promise.allSettled
Promise.all rejects as soon as any one promise rejects. For LLM calls — where transient timeouts and rate-limit errors are facts of life — that means one flaky endpoint can take down an otherwise healthy page. Promise.allSettled waits for every promise and reports each outcome independently:
const [summaryRes, sentimentRes, nextStepsRes] = await Promise.allSettled([ summarise(id), classifySentiment(id), suggestNextSteps(id), ]) const summary = summaryRes.status === 'fulfilled' ? summaryRes.value : null const sentiment = sentimentRes.status === 'fulfilled' ? sentimentRes.value : null const nextSteps = nextStepsRes.status === 'fulfilled' ? nextStepsRes.value : null
Each section of the page can then render a fallback independently rather than surfacing a full error boundary for one failed call.
Combining with Suspense for incremental streaming
Parallel requests give you the fastest possible time-to-all-data. But if you want the user to see something while the slowest call is still in flight, wrap each data-dependent section in its own <Suspense> boundary and split the page into separate async components:
// page.tsx — parent initiates all requests up front
export default async function ReportPage({ params }) {
const { id } = await params
return (
<>
<Suspense fallback={<Skeleton />}>
<Summary id={id} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Sentiment id={id} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<NextSteps id={id} />
</Suspense>
</>
)
}
// Summary.tsx — resolves and streams as soon as summarise() settles
async function Summary({ id }: { id: string }) {
const summary = await summarise(id)
return <SummaryCard data={summary} />
}Next.js renders each <Suspense>boundary independently. The section whose LLM call finishes first streams to the browser first — the slower ones show skeletons until they're ready. The requests themselves all start at the same time because Next.js renders sibling Server Components in parallel by default.
A note on fetch memoisation
If multiple components in the same render tree call the same URL with the same options, Next.js deduplicates the underlying network request automatically — only one fetch goes out. This means you can safely call getUser(id) in both a header component and a content component without worrying about double-fetching. The deduplication is per-render, not persistent; it does not replace a use cache annotation when you want the result to survive across requests.
The pattern in one sentence
Start every independent LLM call before you await any of them; use Promise.allSettledwhen partial failure is acceptable; add <Suspense> boundaries when you want incremental streaming. Three rules, no libraries required.