Next.js 15 Fullstack (App Router + Server Actions) · 课时

流式传输陷阱:布局偏移与瀑布流

诊断并修复连续的数据瀑布和视觉跳动,避免流式页面体验变差。

第 4 / 4 课13 个步骤

流式传输陷阱:布局偏移与瀑布流 是 CoddyKit 上的免费 Next.js 15 Fullstack (App Router + Server Actions) 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Next.js 15 Fullstack (App Router + Server Actions) 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

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:

  • DashboardPage awaits getUser() (200 ms)
  • Then renders <RecentOrders userId={user.id} /> which awaits getOrders(userId) (300 ms)
  • Then renders <Recommendations userId={user.id} /> which awaits getRecommendations(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.all to 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 width and height attributes 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 fetch calls are started before any await and combined with Promise.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/height on next/image; never unspecified dimensions on streamed images.
  • Font stability — use next/font to 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 await calls for independent data. Fix with Promise.all to 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/image with explicit dimensions for all streamed images, and using next/font to 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.

免费开始

用 AI 导师学习 TypeScript — 免费

在浏览器中编写并运行真实代码,获得全天候 AI 导师的即时帮助,并在网页或应用中继续学习。

课程
22
课程
88

常见问题解答

「流式传输陷阱:布局偏移与瀑布流」课时是免费的吗?

是的 — 「流式传输陷阱:布局偏移与瀑布流」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Next.js 15 Fullstack (App Router + Server Actions) 课程的其余内容,请升级到 CoddyKit PRO。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。

「流式传输陷阱:布局偏移与瀑布流」这节课中我会学到什么?

诊断并修复连续的数据瀑布和视觉跳动,避免流式页面体验变差。 你通过在浏览器中直接运行的动手代码来练习 Next.js 15 Fullstack (App Router + Server Actions),全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Next.js 15 Fullstack (App Router + Server Actions) 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Next.js 15 Fullstack (App Router + Server Actions) 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。

「流式传输陷阱:布局偏移与瀑布流」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Next.js 15 Fullstack (App Router + Server Actions) 课中编写并运行代码吗?

能。每节 Next.js 15 Fullstack (App Router + Server Actions) 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. Suspense 边界与组件级流式传输
  2. 打造有意义的 loading.tsx 与骨架屏
  3. 部分预渲染:静态外壳与动态空洞
  4. 流式传输陷阱:布局偏移与瀑布流
← 返回 Next.js 15 Fullstack (App Router + Server Actions)