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.
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.
// 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.
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.
Route-level orchestration keeps the fetch graph explicit and prevents mount-time request chains.
Quick Reference
| Pattern | Problem | Fix | Impact |
|---|---|---|---|
| Early async before branch | Work runs even when function exits early | Move early-exit checks before await | Removes wasted network/database calls |
| Sequential independent awaits | Total latency adds up (A + B + C) | Use Promise.all for independent calls | Collapses wall-clock time to max single call |
| N+1 await in loops | Each iteration blocks the next | Gather IDs, batch with Promise.all, map results | Scales better with list size |
| Parent/child fetch chain | Child fetch starts only after parent render | Fetch in route/page/layout and pass props | Eliminates component-level waterfalls |