Module P-1·13 min read

Sequential vs parallel fetching, unstable_cache and cache(), revalidatePath scope, connection() and unstable_noStore() for opting into dynamic rendering, and eliminating waterfall chains.

Written by Jatin Jain Saraf · Architect of SupraScan

P-1 — Advanced Data Fetching: Patterns, Caching, and Control

Who this is for: Developers who completed the Foundation phase and can build Next.js applications, but who need to move beyond the basics — eliminating waterfall fetches, using unstable_cache for persistent query caching, and understanding exactly which Next.js functions force a page into dynamic rendering. This is where "it works" becomes "it performs."


The Problem With Naive Data Fetching

Foundation-level data fetching — await db.query() in Server Components — is correct. But on a real application with multiple data sources, several failure modes emerge quickly:

Waterfall chains. Component A fetches user data, then renders Component B which fetches preferences, then renders Component C which fetches content. Three sequential round-trips when all three could have run in parallel.

No persistence across requests. React.cache() deduplicates within a single render, but the result is gone the moment the response is sent. Every request rebuilds the same data — a user's profile queried 10,000 times per minute means 10,000 database calls per minute, even when the profile hasn't changed.

Unintentional dynamic rendering. You introduce a cookie check in one small utility function and suddenly a page that was statically generated at build time is now server-rendering on every request. The build output flips from ○ to λ and your CDN cache hit rate collapses.

This module addresses all three.


Sequential vs Parallel — Recognising the Waterfall

The waterfall is the most common performance issue in App Router applications. It happens whenever you await multiple independent fetches sequentially:

// ❌ Sequential — total wait: A + B + C
export default async function ProfilePage({ params }) {
  const { id } = await params;
  const user = await getUser(id);               // 50ms
  const preferences = await getPreferences(id); // 80ms
  const recentActivity = await getActivity(id); // 120ms
  // Total wait: 250ms
}

These three queries don't depend on each other — getPreferences doesn't need the result of getUser. They should run in parallel:

// ✅ Parallel — total wait: max(A, B, C) = C = 120ms
export default async function ProfilePage({ params }) {
  const { id } = await params;
  const [user, preferences, recentActivity] = await Promise.all([
    getUser(id),
    getPreferences(id),
    getActivity(id),
  ]);
  // Total wait: 120ms — 52% faster
}

Spotting the pattern: any time you see multiple await statements at the top level of a Server Component or data function, ask: does the second await need the result of the first? If not, they can be parallelised.

Promise.allSettled for Graceful Degradation

Promise.all rejects if any promise rejects — one failed query takes down the whole fetch. For non-critical data, use Promise.allSettled:

const [userResult, activityResult] = await Promise.allSettled([
  getUser(id),
  getActivity(id),  // this one failing shouldn't break the page
]);
 
const user = userResult.status === 'fulfilled' ? userResult.value : null;
const activity = activityResult.status === 'fulfilled' ? activityResult.value : [];

The profile still renders if activity fails to load. Use Promise.allSettled for supplementary data, Promise.all for essential data where partial failure means the page can't render meaningfully.


Suspense as a Parallelism Tool

Beyond the loading skeleton use-case from F-4, Suspense has a second role: it allows independent subtrees to fetch in parallel without blocking each other, regardless of how deep the component tree goes.

// app/dashboard/page.tsx
 
import { Suspense } from 'react';
import StatsPanel from '@/components/StatsPanel';
import RecentOrders from '@/components/RecentOrders';
import ActivityFeed from '@/components/ActivityFeed';
 
export default async function DashboardPage({ params }) {
  const { userId } = await params;
 
  return (
    <div className="grid grid-cols-3 gap-4">
      {/* These three components fetch independently and in parallel */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel userId={userId} />
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders userId={userId} />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed userId={userId} />
      </Suspense>
    </div>
  );
}

Each Suspense boundary is independent — StatsPanel fetching doesn't block ActivityFeed from starting. The fastest one renders first; slow ones stream in later. The page never waits for the slowest query before showing anything.

Compare this to a single page-level await for all three data sources: the page would be blank until the slowest one resolves.

The mental model: each <Suspense> boundary is its own independent loading state. Design your component tree so slow data is isolated in its own boundaries, not mixed with fast data in the same await.


unstable_cache — Persisting Query Results Across Requests

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.