React Async Waterfall Elimination
Prevents the sequential request waterfall pattern where each component waits for its parent's data before fetching its own. By parallelizing independent fetches, hoisting data dependencies to the route level, and using streaming SSR, this skill ensures pages load as fast as their slowest independent data source rather than the sum of all sequential fetch times.
TL;DR Checklist
- Identify ALL data dependencies for the route before writing any component
- Group independent fetches with
Promise.all()— neverawaitthem sequentially - Hoist shared data fetching to the nearest common ancestor (page or layout)
- Prevent
useEffectfor data fetching — use TanStack Query, SWR, or Server Components - Wrap async-dependent UI sections in
<Suspense>boundaries with skeleton fallbacks - Enable streaming SSR so data arrives progressively, not in one blocked response
- Use
generateMetadatawith parallelfetch()calls for Next.js route data - Prefer React Server Components for initial data fetching to avoid client waterfalls
When to Use
Use this skill when:
- A page loads data sequentially (component A fetches → renders → component B fetches)
- Metrics show high "Time to First Byte" (TTFB) caused by backend data serialization
- Multiple components on the same page fetch overlapping or related data
- Building server-rendered React apps (Next.js, Remix) with multiple data dependencies
- Implementing loading states and skeletons — waterfalls make them appear staggered
- Reviewing an existing codebase for performance issues related to data fetching patterns
When NOT to Use
Avoid this skill for:
- Single data-fetch operations with no dependent child fetches (no waterfall to eliminate)
- Client-only apps where all data loads in parallel via a single GraphQL query
- Pages with only one data dependency (parallelization adds complexity without benefit)
- Real-time data that must refetch on every render (WebSockets, SSE — different pattern)
Core Workflow
Map All Data Dependencies — Before writing code, list every piece of data the route needs. Note which fetches are independent and which depend on previous results. Checkpoint: If any fetch starts inside a component that renders conditionally based on a parent fetch, you have a waterfall.
Hoist Independent Fetches — Move all parallelizable data fetching to the page or layout level. Group independent fetches with
Promise.all(). Checkpoint: Verify no child component initiates its ownuseEffectfetch on mount.Wrap Dependent UI in Suspense — For data that can't be hoisted (depends on user interaction), wrap it in
<Suspense>with a skeleton fallback. This enables streaming — the page renders while slow data loads.Implement Streaming SSR — Use React 19's streaming APIs or Next.js
loading.tsxto progressively deliver HTML as each Suspense boundary resolves. Checkpoint: Verify the initial HTML response contains shell content, not a blank page waiting for all data.Deduplicate Across Routes — Use
React.cache()in Server Components or query client cache in client components to prevent duplicate fetches when the same data is needed by sibling components.
Implementation Patterns
Pattern 1: Parallel Fetching with Promise.all (BAD vs. GOOD)
// ❌ BAD: Sequential awaits create a waterfall
// Total time = A.time + B.time + C.time (e.g. 200ms + 300ms + 150ms = 650ms)
async function Page() {
const user = await fetch('/api/user').then(r => r.json());
// posts fetch doesn't start until user resolves
const posts = await fetch(`/api/users/${user.id}/posts`).then(r => r.json());
// notifications fetch doesn't start until posts resolves
const notifications = await fetch(`/api/users/${user.id}/notifications`).then(r => r.json());
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
<NotificationBell count={notifications.length} />
</div>
);
}
// ✅ GOOD: Parallel fetches, no waterfall
// Total time = max(A.time, B.time, C.time) (e.g. max(200ms, 300ms, 150ms) = 300ms)
async function Page() {
// All three fetches start simultaneously
const [user, posts, notifications] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/notifications').then(r => r.json()),
]);
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
<NotificationBell count={notifications.length} />
</div>
);
}
Pattern 2: Hoisting with React Query's useQueries
When fetches must happen in child components, use TanStack Query's parallel query hooks to hoist the fetching orchestration while keeping data colocated.
import { useQueries } from '@tanstack/react-query';
// ── Custom hook hoists parallel fetching ───────────────
function useUserDashboard(userId: string) {
return useQueries({
queries: [
{
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5 min
},
{
queryKey: ['user-posts', userId],
queryFn: () => fetch(`/api/users/${userId}/posts`).then(r => r.json()),
staleTime: 2 * 60 * 1000,
},
{
queryKey: ['user-stats', userId],
queryFn: () => fetch(`/api/users/${userId}/stats`).then(r => r.json()),
staleTime: 10 * 60 * 1000,
},
],
});
}
// ── Component uses the hoisted hook ─────────────────────
function UserDashboard({ userId }: { userId: string }) {
const [userQuery, postsQuery, statsQuery] = useUserDashboard(userId);
if (userQuery.isLoading || postsQuery.isLoading || statsQuery.isLoading) {
return <DashboardSkeleton />;
}
return (
<div>
<UserProfile user={userQuery.data} />
<PostList posts={postsQuery.data} />
<UserStats stats={statsQuery.data} />
</div>
);
}
Pattern 3: Suspense Boundaries with Streaming
Wrap components that depend on slow data in Suspense boundaries. React streams each boundary's content as it resolves, progressively enhancing the page.
import { Suspense } from 'react';
// ── Async Server Component ─────────────────────────────
async function SlowDataComponent() {
// This triggers streaming — the page shell renders immediately
const data = await fetch('https://api.example.com/slow-endpoint').then(r => r.json());
return <ExpensiveChart data={data} />;
}
// ── Fast shell renders immediately ──────────────────────
function Page() {
return (
<div>
<h1>Dashboard</h1>
{/* This renders immediately */}
<Navigation />
{/* This streams in when SlowDataComponent resolves */}
<Suspense fallback={<ChartSkeleton />}>
<SlowDataComponent />
</Suspense>
{/* Multiple Suspense boundaries stream independently */}
<Suspense fallback={<ListSkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
Pattern 4: Client-Side Parallel Fetching with AbortController
import { useEffect, useState } from 'react';
interface DashboardData {
user: { name: string };
posts: Array<{ id: number; title: string }>;
metrics: { views: number; clicks: number };
}
function useDashboardData(userId: string) {
const [data, setData] = useState<DashboardData | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
async function fetchAll() {
try {
setLoading(true);
const [user, posts, metrics] = await Promise.all([
fetch(`/api/users/${userId}`, { signal }).then(r => r.json()),
fetch(`/api/users/${userId}/posts`, { signal }).then(r => r.json()),
fetch(`/api/users/${userId}/metrics`, { signal }).then(r => r.json()),
]);
setData({ user, posts, metrics });
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err);
}
} finally {
setLoading(false);
}
}
fetchAll();
// Cleanup: cancel in-flight requests on unmount
return () => controller.abort();
}, [userId]);
return { data, error, loading };
}
Constraints
MUST DO
- Use
Promise.all()for all independent data fetches — neverawaitthem sequentially - Hoist shared data fetching to the nearest common ancestor (page or layout component)
- Wrap async components in
<Suspense>boundaries with meaningful fallback UIs - Use AbortController to cancel in-flight requests when the component unmounts
- Prefer React Server Components for initial data fetching in Next.js apps
- Use
React.cache()in Server Components to deduplicate fetches across boundaries
MUST NOT DO
- Fetch data inside
useEffect(creates client waterfalls) — use TanStack Query or SWR instead - Fetch inside child components that only render after parent data loads (nested waterfalls)
- Block rendering with sequential
awaitstatements when fetches are independent - Fetch the same data from multiple components without deduplication
- Use client components for data fetching when a Server Component would suffice
Related Skills
| Skill | Purpose |
|---|---|
react-server-performance |
Server Components, caching, and streaming for SSR optimization |
react-client-data-fetching |
Client-side data fetching with TanStack Query and SWR |
react-rerender-optimization |
Prevent unnecessary re-renders after data arrives |
react-bundle-size |
Code splitting and lazy loading for data-heavy pages |
Live References
Authoritative documentation links for this skill's domain. The model follows markdown links at load time to resolve external references and inline content.