Data Fetching Without Waterfalls
Waterfalls (sequential data fetches where each request waits for the previous one to complete) kill performance. Sequential awaits can make pages extremely slow. Parallelize to cut p95 latency (the threshold that 95% of your users experience or better) and stabilize UX.
Outcome
Endpoints/pages that fetch multiple sources concurrently with clear ownership.
Fast Track
- Identify sequential awaits.
- Refactor to
Promise.all()or equivalent concurrency. - Handle errors gracefully with
Promise.allSettled()when appropriate.
Hands-On Exercise 2.5
Eliminate async waterfalls with parallel data fetching.
Requirements:
- Refactor a page loading two+ resources sequentially into parallel fetches.
- Show a before/after timing comparison in logs (document the improvement).
- Ensure error aggregation is handled sanely.
- Handle partial failures gracefully using
Promise.allSettled()when appropriate.
Implementation hints:
- Sequential awaits kill performance: Three 200ms requests sequentially = 600ms total. Same requests with
Promise.all()= 200ms total (runs in parallel). - Async params/searchParams: Remember to await params and searchParams before using them in fetches. Include them in your
Promise.all()for optimal performance. - Use Promise.all(): Fetch multiple data sources simultaneously for dramatic loading time reduction.
- Keep fetches independent: Maximize overlap by ensuring requests don't depend on each other.
- Parallel pattern with params:
const [{ slug }, userData, posts] = await Promise.all([params, fetchUser(), fetchPosts()])- await params alongside your data fetches. - Partial failures: Use
Promise.allSettled()when some data is optional and the page can render without it. - Error aggregation: Handle failures gracefully without masking root cause.
- Better user experience with faster page loads.
Parallel Fetching and Caching
Parallel fetching is a prerequisite for effective caching. When you combine both patterns, you get the best of both worlds:
| Strategy | When to Use |
|---|---|
| Parallel only | All fetches are fast (<100ms) |
| Cache only | Single expensive fetch |
| Parallel + Cache | Multiple fetches, some expensive |
// Parallel + Cache: Best of both worlds
const [product, reviews, recommendations] = await Promise.all([
getProduct(id), // Cached (expensive)
getReviews(id), // Fresh (user-generated)
getRecommendations(id) // Cached (expensive)
])Why parallel matters for caching:
- Cached fetches return instantly from cache
- Fresh fetches run in parallel, not blocked by cached ones
- Total time = max(cached, fresh), not sum of all
Try It
- Measure response time before and after (terminal timing/logs).
Commit & Deploy
git add -A
git commit -m "feat(core): eliminate async waterfalls with parallel fetches"
git push -u origin feat/core-parallel-fetchDone-When
- Navigate to
/data-demo: page shows "Parallel fetch completed in ~XXXms" banner - Verify timing shows ~300ms (not ~750ms that sequential would take)
- Code review: page uses
Promise.all()to fetch user, posts, and stats simultaneously - Console logs (or server terminal) show no sequential await patterns for independent data
Solution
Solution
This solution demonstrates parallel data fetching with timing comparison and resilient error handling.
Step 1: Create Data Fetching Functions with Simulated Delays
// Simulated API calls with realistic delays
// In production, these would be actual fetch calls to your API
export async function fetchUser(): Promise<{ name: string; email: string }> {
await new Promise((resolve) => setTimeout(resolve, 200));
return { name: "John Doe", email: "john@example.com" };
}
export async function fetchPosts(): Promise<Array<{ id: number; title: string }>> {
await new Promise((resolve) => setTimeout(resolve, 300));
return [
{ id: 1, title: "Getting Started with Next.js" },
{ id: 2, title: "Server Components Deep Dive" },
{ id: 3, title: "Caching Strategies" },
];
}
export async function fetchStats(): Promise<{ views: number; likes: number }> {
await new Promise((resolve) => setTimeout(resolve, 250));
return { views: 1234, likes: 567 };
}Step 2: Implement Parallel Fetching with Timing Comparison
import { fetchPosts, fetchStats, fetchUser } from "./data";
// Parallel fetch with timing measurement
async function fetchParallel() {
const startTime = performance.now();
// Promise.all runs all fetches simultaneously
// Total time = max(200, 300, 250) = ~300ms instead of 750ms sequential
const [user, posts, stats] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchStats(),
]);
const endTime = performance.now();
const duration = Math.round(endTime - startTime);
return { user, posts, stats, duration };
}
export default async function DataDemoPage() {
const { user, posts, stats, duration } = await fetchParallel();
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="mb-6 font-bold text-3xl">
Data Fetching Without Waterfalls
</h1>
{/* Performance result banner */}
<div className="mb-6 rounded-lg border-2 border-green-200 bg-green-50 p-4">
<h2 className="font-semibold text-green-800">Performance Result</h2>
<p className="text-green-700">
Parallel fetch completed in{" "}
<span className="font-bold font-mono">{duration}ms</span>
</p>
<p className="mt-2 text-green-600 text-sm">
Sequential would take ~750ms (200 + 300 + 250ms). Parallel takes
~300ms (max of all three).
</p>
</div>
<div className="space-y-6">
{/* User data */}
<div className="rounded-lg border p-4">
<h2 className="mb-2 font-semibold">User (200ms fetch)</h2>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
</div>
{/* Posts data */}
<div className="rounded-lg border p-4">
<h2 className="mb-2 font-semibold">Posts (300ms fetch)</h2>
<ul className="list-inside list-disc">
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
{/* Stats data */}
<div className="rounded-lg border p-4">
<h2 className="mb-2 font-semibold">Stats (250ms fetch)</h2>
<p>Views: {stats.views.toLocaleString()}</p>
<p>Likes: {stats.likes.toLocaleString()}</p>
</div>
</div>
{/* Code comparison */}
<div className="mt-8 rounded bg-gray-100 p-4">
<h3 className="mb-2 font-semibold">Key Takeaway</h3>
<pre className="overflow-x-auto font-mono text-sm">
{`// ❌ Sequential (750ms total)
const user = await fetchUser(); // 200ms
const posts = await fetchPosts(); // 300ms
const stats = await fetchStats(); // 250ms
// ✅ Parallel (300ms total)
const [user, posts, stats] = await Promise.all([
fetchUser(), // All run
fetchPosts(), // simultaneously
fetchStats(), // max(200,300,250) = 300ms
]);`}
</pre>
</div>
</main>
);
}Step 3: Include Async Params in Promise.all (Next.js 16 Pattern)
When your page has dynamic route parameters, include params in your Promise.all():
import { fetchPosts, fetchUser, fetchUserStats } from "@/lib/data";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function UserPage({ params }: PageProps) {
// Include params in Promise.all for optimal performance
const [{ id }, user, posts, stats] = await Promise.all([
params, // Await params alongside data fetches
fetchUser(), // These run in parallel with params resolution
fetchPosts(),
fetchUserStats(),
]);
// Now use id for any dependent fetches
// (Better: restructure so all independent fetches are in Promise.all)
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="mb-4 font-bold text-2xl">User {id}</h1>
<p>{user.name}</p>
{/* ... */}
</main>
);
}Step 4: Error Handling with Promise.allSettled
For resilient error handling where partial data is acceptable:
async function fetchDashboardData() {
const results = await Promise.allSettled([
fetchUser(),
fetchPosts(),
fetchStats(),
]);
// Extract successful results, provide fallbacks for failures
const user = results[0].status === "fulfilled"
? results[0].value
: { name: "Guest", email: "" };
const posts = results[1].status === "fulfilled"
? results[1].value
: [];
const stats = results[2].status === "fulfilled"
? results[2].value
: { views: 0, likes: 0 };
// Log failures for monitoring (add biome-ignore if using console.log)
results.forEach((result, index) => {
if (result.status === "rejected") {
// biome-ignore lint/suspicious/noConsole: monitoring
console.error(`Fetch ${index} failed:`, result.reason);
}
});
return { user, posts, stats };
}Key Decisions Explained
-
Promise.all over sequential awaits: Parallel execution reduces total time from sum of all requests to the max single request time.
-
Include params in Promise.all: In Next.js 16+, params are async. Including them in Promise.all prevents an extra sequential await.
-
Promise.allSettled for resilience: When partial data is acceptable, this prevents one failure from breaking the entire page.
-
Foundation for caching: This parallel pattern is the foundation for effective caching. In Lesson 3.1, you'll learn to add
"use cache"to expensive functions likefetchUser()andfetchStats(). Cached functions return instantly, while fresh functions (like reviews) run in parallel without blocking.
References
Was this helpful?