ReactField/Eliminating Async Waterfalls
Getting Started·1 min read·Updated Mar 2026

Eliminating Async Waterfalls in React

Learn practical patterns to remove sequential async bottlenecks and ship faster React experiences.

DocsReact 19TypeScript

Overview

Main content

Active

Eliminating Async Waterfalls in React

Async waterfalls happen when we await operations one-by-one even though they could run at the same time. In most codebases, this starts innocently: a handler grows over time, another fetch is added, then another, and now each request waits for the previous one to finish even when there is no real dependency.

The cost is measurable and repeated across your entire user base. A 600ms waterfall vs 200ms parallel path is not just a one-time optimization; every user session pays this tax. Reducing waterfall patterns is one of the highest leverage performance improvements you can make without changing infrastructure.

Impact: lower latency on every request path

Rule 1 — Defer async calls past early-exit conditions

When a branch can return early, evaluate that branch before starting expensive async work.

Incorrecttsx
async function handleRequest(userId: string, skipProcessing: boolean) {
const userData = await fetchUserData(userId) // waited for nothing
if (skipProcessing) return { skipped: true }
return processUserData(userData)
}
Correcttsx
async function handleRequest(userId: string, skipProcessing: boolean) {
if (skipProcessing) return { skipped: true }
const userData = await fetchUserData(userId)
return processUserData(userData)
}

The bug is subtle: the await ran even when its result was never used. Early returns are free wins for both latency and resource usage.

Rule 2 — Parallelize independent awaits

If calls do not depend on each other, run them concurrently.

Incorrect (Sequential)tsx
async function loadDashboardData() {
const user = await fetchUser()
const posts = await fetchPosts()
const settings = await fetchSettings()
return { user, posts, settings }
}
Correct (Parallel)tsx
async function loadDashboardData() {
const [user, posts, settings] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchSettings(),
])
return { user, posts, settings }
}
text
// Timing diagram:
// Sequential: 300ms + 300ms + 300ms = 900ms
// Parallel:   max(300ms, 300ms, 300ms) = 300ms

Note: only parallelize calls that are truly independent.

Rule 3 — Avoid N+1 patterns

A common waterfall appears inside loops: each iteration awaits before moving to the next.

Incorrect (N+1 loop)tsx
async function hydratePosts(posts: Array<{ id: string; authorId: string }>) {
const enriched = []
for (const post of posts) {
const author = await fetchUserById(post.authorId)
enriched.push({ ...post, author })
}
return enriched
}
Correct (batch and parallelize)tsx
async function hydratePosts(posts: Array<{ id: string; authorId: string }>) {
const uniqueAuthorIds = [...new Set(posts.map((post) => post.authorId))]
const authorEntries = await Promise.all(
uniqueAuthorIds.map(async (id) => [id, await fetchUserById(id)] as const)
)
const authorMap = new Map(authorEntries)
return posts.map((post) => ({
...post,
author: authorMap.get(post.authorId),
}))
}

Real-world example: fetching author details for a list of post cards. The looped await becomes a latency multiplier as list size grows.

Rule 4 — Component-level waterfalls

Classic React waterfall:

  • Parent fetches data
  • Parent renders child
  • Child starts its own fetch after mount

This serializes work across component boundaries.

Incorrect (Parent then Child waterfall)tsx
// Parent.tsx
export default function Parent() {
const [account, setAccount] = useState<Account | null>(null)
useEffect(() => {
fetchAccount().then(setAccount)
}, [])
if (!account) return <Spinner />
return <Child accountId={account.id} />
}
// Child.tsx
function Child({ accountId }: { accountId: string }) {
const [activity, setActivity] = useState<Activity[]>([])
useEffect(() => {
fetchActivity(accountId).then(setActivity)
}, [accountId])
return <ActivityFeed items={activity} />
}
Correct (Route-level parallel fetching)tsx
// app/dashboard/page.tsx (Next.js App Router)
export default async function DashboardPage() {
const [account, notifications] = await Promise.all([
fetchAccount(),
fetchNotifications(),
])
const [activity, recommendations] = await Promise.all([
fetchActivity(account.id),
fetchRecommendations(account.id),
])
return (
<DashboardView
account={account}
notifications={notifications}
activity={activity}
recommendations={recommendations}
/>
)
}

Route-level orchestration keeps the fetch graph explicit and prevents mount-time request chains.

Quick Reference

PatternProblemFixImpact
Early async before branchWork runs even when function exits earlyMove early-exit checks before awaitRemoves wasted network/database calls
Sequential independent awaitsTotal latency adds up (A + B + C)Use Promise.all for independent callsCollapses wall-clock time to max single call
N+1 await in loopsEach iteration blocks the nextGather IDs, batch with Promise.all, map resultsScales better with list size
Parent/child fetch chainChild fetch starts only after parent renderFetch in route/page/layout and pass propsEliminates component-level waterfalls