Streaming Pitfalls: Layout Shift and Waterfalls
Diagnose and fix sequential data waterfalls and visual jumps that degrade streamed pages.
Streaming Pitfalls: Layout Shift and Waterfalls is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Streaming Goes Wrong
Streaming with React Suspense and Next.js 15 is powerful, but it introduces two categories of problems that silently degrade user experience:
- Layout Shift — the page visually jumps when streamed content arrives and replaces a skeleton or placeholder, causing elements to reposition.
- Data Waterfalls — components fetch data sequentially rather than in parallel, so the total load time is the sum of all fetch durations instead of the maximum.
These problems are distinct but often appear together. A waterfall delays when content arrives, and when it finally does arrive, a poorly designed skeleton causes a layout shift. This lesson teaches you to diagnose both and apply targeted fixes.
Anatomy of a Data Waterfall
A waterfall happens when one async Server Component awaits its data before rendering children that also need to fetch data. Because the parent suspends first, children never start fetching until the parent resolves.
Consider this structure:
DashboardPageawaitsgetUser()(200 ms)- Then renders
<RecentOrders userId={user.id} />which awaitsgetOrders(userId)(300 ms) - Then renders
<Recommendations userId={user.id} />which awaitsgetRecommendations(userId)(400 ms)
Total wait: 200 + 300 + 400 = 900 ms. With parallel fetches it could be max(200, 300, 400) = 400 ms. The cascade is the waterfall.
Spotting a Waterfall in Code
The telltale sign of a waterfall is sequential await calls where results are not interdependent, or passing fetched data as props into children that then fetch more data themselves.
The example below shows a page-level waterfall. Notice how RecentOrders cannot start loading until getUser finishes, even though userId could have been passed from a session or URL param instead.
// app/dashboard/page.tsx — PROBLEMATIC: sequential waterfall
import RecentOrders from './_components/RecentOrders';
import Recommendations from './_components/Recommendations';
async function getUser() {
const res = await fetch('https://api.example.com/me');
return res.json(); // takes ~200 ms
}
export default async function DashboardPage() {
// Step 1: wait for user
const user = await getUser();
// Step 2: children only mount AFTER step 1 finishes.
// Each child will then do its own async fetch.
return (
<main>
<h1>Welcome, {user.name}</h1>
<RecentOrders userId={user.id} />
<Recommendations userId={user.id} />
</main>
);
}Breaking the Waterfall with Parallel Fetches
The primary fix is to initiate all independent fetches at the same time using Promise.all (or separate fetch calls that are not awaited until needed). When you need to pass data down, hoist the parallel fetches to the page level.
Key rules:
- Start every independent fetch before the first
await. - Use
Promise.allto wait for all of them together. - Pass resolved data as props; children no longer need to fetch.
// app/dashboard/page.tsx — FIXED: parallel fetches with Promise.all
async function getUser() {
const res = await fetch('https://api.example.com/me');
return res.json();
}
async function getOrders(userId: string) {
const res = await fetch(`https://api.example.com/orders?userId=${userId}`);
return res.json();
}
async function getRecommendations(userId: string) {
const res = await fetch(`https://api.example.com/recommendations?userId=${userId}`);
return res.json();
}
export default async function DashboardPage() {
// Kick off user fetch first to obtain userId
const user = await getUser();
// Now fetch independent data in parallel
const [orders, recommendations] = await Promise.all([
getOrders(user.id),
getRecommendations(user.id),
]);
return (
<main>
<h1>Welcome, {user.name}</h1>
<RecentOrders orders={orders} />
<Recommendations items={recommendations} />
</main>
);
}Suspense Boundaries and Parallel Streaming
When you wrap independent async components in separate <Suspense> boundaries, Next.js can stream each section as it resolves — without waiting for siblings. This gives users partial content immediately.
The anti-pattern is wrapping everything in a single <Suspense>: the whole section waits for the slowest child. Split boundaries granularly so fast components appear first.
// app/dashboard/page.tsx — granular Suspense for parallel streaming
import { Suspense } from 'react';
import RecentOrders from './_components/RecentOrders';
import Recommendations from './_components/Recommendations';
import OrdersSkeleton from './_components/OrdersSkeleton';
import RecommendationsSkeleton from './_components/RecommendationsSkeleton';
export default function DashboardPage() {
// No await here — let each child fetch independently and stream in
return (
<main>
<h1>Dashboard</h1>
{/* Each boundary resolves independently */}
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</main>
);
}What Causes Layout Shift in Streamed Pages
Layout Cumulative Layout Shift (CLS) in streamed pages occurs when the skeleton placeholder has different dimensions than the real content that replaces it. When React swaps the fallback for live content, surrounding elements reposition — a jarring visual jump.
Common causes:
- A skeleton that is shorter or taller than the actual component.
- Images without explicit
widthandheightattributes that cause reflow on load. - Font loading causing text reflow after content streams in.
- Conditionally rendered elements that change page height after hydration.
The fix is to make your skeletons dimensionally accurate — same height, padding, and grid structure as the real component.
Writing a Dimensionally Accurate Skeleton
A good skeleton mirrors the real component's layout grid. Use fixed heights, matching gap values, and the same number of placeholder rows as the real list will likely render. Tailwind's animate-pulse utility handles the shimmer effect.
The example below pairs a real OrdersList component with a skeleton that has the same outer height and row structure, preventing layout shift when the real data streams in.
// app/dashboard/_components/OrdersSkeleton.tsx
export default function OrdersSkeleton() {
return (
<div className="space-y-3" aria-busy="true" aria-label="Loading orders">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-16 rounded-lg bg-gray-200 animate-pulse"
// h-16 matches the real OrderRow height of 4rem
/>
))}
</div>
);
}
// app/dashboard/_components/RecentOrders.tsx
type Order = { id: string; total: number; createdAt: string };
async function fetchOrders(): Promise<Order[]> {
const res = await fetch('https://api.example.com/orders', {
next: { revalidate: 60 },
});
return res.json();
}
export default async function RecentOrders() {
const orders = await fetchOrders();
return (
<div className="space-y-3">
{orders.map((order) => (
<div key={order.id} className="h-16 rounded-lg border px-4 flex items-center">
<span>#{order.id}</span>
<span className="ml-auto">${order.total}</span>
</div>
))}
</div>
);
}Reserving Space for Images to Prevent Shift
Images are a leading cause of layout shift in streamed content. When an <img> loads after the skeleton is replaced, the browser does not know its dimensions and allocates no space — then suddenly reflows the page.
The fix is always to provide width and height attributes (or CSS aspect-ratio) so the browser reserves the exact space before the image loads. Next.js's built-in Image component enforces this automatically when you provide width and height props.
// app/dashboard/_components/ProductCard.tsx
import Image from 'next/image';
type Product = {
id: string;
name: string;
imageUrl: string;
};
export default function ProductCard({ product }: { product: Product }) {
return (
<div className="rounded-lg border p-4">
{/*
width + height props tell the browser to reserve 200x200 px
BEFORE the image bytes arrive — zero layout shift.
next/image also lazy-loads and serves optimised WebP automatically.
*/}
<Image
src={product.imageUrl}
alt={product.name}
width={200}
height={200}
className="rounded object-cover"
/>
<p className="mt-2 font-medium">{product.name}</p>
</div>
);
}Diagnosing Waterfalls with the React DevTools Profiler
Before fixing, you need to confirm a waterfall exists. Two tools help:
- React DevTools Profiler — record a page load and look at the Flamegraph. Suspense boundaries that resolve one after another in a staircase pattern signal a waterfall.
- Chrome DevTools Network tab — filter by Fetch/XHR. If requests start only after previous ones finish (a staircase in the waterfall view), you have a waterfall.
In Next.js 15, you can also enable verbose logging by setting logging: { fetches: { fullUrl: true } } in next.config.ts to see every server-side fetch with its timing in the terminal during development.
// next.config.ts — enable fetch logging to diagnose waterfalls in dev
import type { NextConfig } from 'next';
const config: NextConfig = {
logging: {
fetches: {
fullUrl: true, // prints each fetch URL + cache status + duration
},
},
};
export default config;Deferring Non-Critical Sections with use()
Sometimes you cannot eliminate a dependency but want to avoid blocking the whole page. React 19's use() hook (available in Next.js 15 App Router) lets you pass a Promise as a prop and suspend only the component that consumes it — not the parent.
This pattern lets the parent render instantly with whatever data it has, then stream in the slower section. It is the typed, idiomatic successor to the old trick of passing promises down as props.
// app/dashboard/page.tsx — defer slow section with use()
import { Suspense } from 'react';
import SlowWidget from './_components/SlowWidget';
import SlowWidgetSkeleton from './_components/SlowWidgetSkeleton';
async function getSlowData() {
const res = await fetch('https://api.example.com/slow-metric', {
next: { revalidate: 30 },
});
return res.json() as Promise<{ value: number }>;
}
export default function DashboardPage() {
// Start the fetch but do NOT await — pass the Promise directly
const slowDataPromise = getSlowData();
return (
<main>
<h1>Dashboard</h1>
{/* Page renders immediately; SlowWidget suspends on its own */}
<Suspense fallback={<SlowWidgetSkeleton />}>
<SlowWidget dataPromise={slowDataPromise} />
</Suspense>
</main>
);
}
// app/dashboard/_components/SlowWidget.tsx
'use client';
import { use } from 'react';
type SlowWidgetProps = { dataPromise: Promise<{ value: number }> };
export default function SlowWidget({ dataPromise }: SlowWidgetProps) {
const data = use(dataPromise); // suspends here, not in the parent
return <p>Metric: {data.value}</p>;
}Combining All Fixes: A Checklist
Apply these checks to every page that uses streaming:
- Parallel fetches — verify that independent
fetchcalls are started before anyawaitand combined withPromise.all. - Granular Suspense — each independently streamed section has its own
<Suspense>boundary; never one giant boundary around the whole page. - Dimensionally accurate skeletons — skeleton height, padding, and grid match the real component to avoid CLS.
- Images with reserved dimensions — always
width/heightonnext/image; never unspecified dimensions on streamed images. - Font stability — use
next/fontto load fonts at build time and avoid FOUT-driven reflow after hydration. - Defer with
use()— for unavoidably slow sections, pass a Promise prop and let the child suspend rather than blocking the parent.
Knowledge Check: Fixing a Sequential Waterfall
Review the following Next.js 15 Server Component. A performance audit shows that ProductList and ReviewSummary are loading sequentially instead of in parallel, causing a 600 ms waterfall. Which change best fixes this?
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id); // 200 ms
const reviews = await getReviews(params.id); // 400 ms
return (
<>
<ProductList product={product} />
<ReviewSummary reviews={reviews} />
</>
);
}Recap: Streaming Pitfalls at a Glance
This lesson covered the two most common pitfalls in streamed Next.js 15 pages and how to fix them:
- Data Waterfalls — caused by sequential
awaitcalls for independent data. Fix withPromise.allto parallelise fetches, and with granular<Suspense>boundaries so each section streams in as soon as its own data is ready. - Layout Shift — caused by skeleton placeholders that do not match the dimensions of real content. Fix by matching skeleton height and structure to the real component, using
next/imagewith explicit dimensions for all streamed images, and usingnext/fontto prevent font-driven reflow. - Deferring slow sections — React 19's
use()hook lets you pass a Promise as a prop so only the consuming child suspends, keeping the rest of the page unblocked.
Always diagnose with the Network waterfall tab and Next.js fetch logging before optimising, so you fix real bottlenecks rather than guessing.
Frequently asked questions
Is the “Streaming Pitfalls: Layout Shift and Waterfalls” lesson free?
Yes — the full text of “Streaming Pitfalls: Layout Shift and Waterfalls” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.
What will I learn in “Streaming Pitfalls: Layout Shift and Waterfalls”?
Diagnose and fix sequential data waterfalls and visual jumps that degrade streamed pages. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?
No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Streaming Pitfalls: Layout Shift and Waterfalls” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?
Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Suspense Boundaries and Component-Level Streaming
- Crafting Meaningful loading.tsx and Skeletons
- Partial Prerendering: Static Shell, Dynamic Holes
- Streaming Pitfalls: Layout Shift and Waterfalls