Next.js Streaming SSR: 5 Production Patterns from Suspense to Progressive Rendering

前端工程

Why Is Traditional SSR's TTFB Getting Slower and Slower

Your e-commerce homepage uses traditional SSR, and first-screen TTFB has climbed from 800ms to 3 seconds — because data fetching is waterfall-style: wait for user info, then product list, then recommendations, then reviews. The entire component tree must wait for the slowest request before sending the first byte. Users stare at a blank screen while LCP explodes.

Next.js 15's Streaming SSR uses Suspense boundaries to chunk HTML and stream it — sending the static shell first, then appending dynamic parts as they resolve. This article walks you through Suspense boundary layout → streaming HTML with loading.tsx → progressive hydration → parallel data fetching → production error boundaries — 5 production patterns that reduce TTFB by 60%+.


Streaming SSR Core Concepts

Concept Description
Streaming SSR Streaming server-side rendering, chunked HTML transfer without waiting for all data
Suspense React concurrent feature, declares async loading boundaries for chunked rendering
Progressive Rendering Page content renders progressively, users see usable content first
Hydration Associating server HTML with client JS to make the page interactive
React Server Components (RSC) Server components, zero client JS, native Streaming support
App Router Next.js 15 routing system with native Streaming SSR and RSC support

Traditional SSR vs Streaming SSR

Traditional SSR:
Request arrives → Wait for all data → Generate complete HTML → Send at once → TTFB = slowest request time

Streaming SSR:
Request arrives → Send static shell immediately → Suspense chunking → Append each chunk as data resolves → TTFB ≈ 0

Problem Analysis: 5 Major Challenges with Traditional SSR

  1. Waterfall data fetching: Nested components cause serial fetches, TTFB = fetchA + fetchB + fetchC, growing linearly
  2. Blocking rendering: The entire tree must wait for the slowest node; fast data is dragged down by slow data
  3. Hydration bottleneck: Large pages hydrate all at once, blocking the main thread and degrading INP
  4. Error avalanche: One data source failure causes a full-page 500 with no degradation capability
  5. Coarse cache granularity: Full-page caching can't distinguish static/dynamic parts; frequent invalidation leads to low hit rates

Step-by-Step: 5 Production Patterns

Pattern 1: Suspense Boundary Layout with Streaming

// app/page.tsx
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <div className="min-h-screen bg-gray-50">
      <DashboardHeader />
      <main className="container mx-auto px-4 py-6">
        <Suspense fallback={<StatsCardsSkeleton />}>
          <StatsCards />
        </Suspense>
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
          <Suspense fallback={<RevenueChartSkeleton />}>
            <RevenueChart />
          </Suspense>
          <Suspense fallback={<RecentOrdersSkeleton />}>
            <RecentOrders />
          </Suspense>
        </div>
        <Suspense fallback={<ActivityFeedSkeleton />}>
          <ActivityFeed />
        </Suspense>
      </main>
    </div>
  );
}
// app/components/StatsCards.tsx
async function StatsCards() {
  const stats = await fetchDashboardStats();

  return (
    <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
      <StatCard title="Total Revenue" value={`$${stats.revenue.toLocaleString()}`} change={stats.revenueChange} />
      <StatCard title="Orders" value={stats.orders.toLocaleString()} change={stats.ordersChange} />
      <StatCard title="Users" value={stats.users.toLocaleString()} change={stats.usersChange} />
      <StatCard title="Conversion" value={`${stats.conversionRate}%`} change={stats.conversionChange} />
    </div>
  );
}

function StatCard({ title, value, change }: { title: string; value: string; change: number }) {
  const isPositive = change >= 0;
  return (
    <div className="bg-white rounded-lg shadow-sm p-6">
      <p className="text-sm text-gray-500">{title}</p>
      <p className="text-2xl font-bold mt-1">{value}</p>
      <p className={`text-sm mt-2 ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
        {isPositive ? '↑' : '↓'} {Math.abs(change)}%
      </p>
    </div>
  );
}
// app/components/skeletons.tsx
export function StatsCardsSkeleton() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
      {Array.from({ length: 4 }).map((_, i) => (
        <div key={i} className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
          <div className="bg-gray-200 h-4 rounded w-1/2" />
          <div className="bg-gray-200 h-8 rounded w-3/4 mt-3" />
          <div className="bg-gray-200 h-4 rounded w-1/3 mt-3" />
        </div>
      ))}
    </div>
  );
}

export function RevenueChartSkeleton() {
  return (
    <div className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
      <div className="bg-gray-200 h-6 rounded w-1/3" />
      <div className="bg-gray-200 h-64 rounded mt-4" />
    </div>
  );
}

export function RecentOrdersSkeleton() {
  return (
    <div className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
      <div className="bg-gray-200 h-6 rounded w-1/3" />
      {Array.from({ length: 5 }).map((_, i) => (
        <div key={i} className="flex gap-4 mt-4">
          <div className="bg-gray-200 h-4 rounded w-1/4" />
          <div className="bg-gray-200 h-4 rounded w-1/4" />
          <div className="bg-gray-200 h-4 rounded w-1/6" />
          <div className="bg-gray-200 h-4 rounded w-1/6" />
        </div>
      ))}
    </div>
  );
}

export function ActivityFeedSkeleton() {
  return (
    <div className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
      <div className="bg-gray-200 h-6 rounded w-1/4" />
      {Array.from({ length: 8 }).map((_, i) => (
        <div key={i} className="flex gap-3 mt-4">
          <div className="bg-gray-200 h-10 w-10 rounded-full" />
          <div className="flex-1">
            <div className="bg-gray-200 h-4 rounded w-1/2" />
            <div className="bg-gray-200 h-3 rounded w-3/4 mt-2" />
          </div>
        </div>
      ))}
    </div>
  );
}

Pattern 2: Streaming HTML with loading.tsx Fallbacks

// app/products/loading.tsx
export default function ProductsLoading() {
  return (
    <div className="min-h-screen bg-gray-50">
      <div className="container mx-auto px-4 py-8">
        <div className="animate-pulse">
          <div className="bg-gray-200 h-10 rounded w-1/4" />
          <div className="bg-gray-200 h-6 rounded w-1/2 mt-4" />
        </div>
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-8">
          {Array.from({ length: 8 }).map((_, i) => (
            <div key={i} className="bg-white rounded-lg shadow-sm overflow-hidden animate-pulse">
              <div className="bg-gray-200 h-48" />
              <div className="p-4">
                <div className="bg-gray-200 h-5 rounded w-3/4" />
                <div className="bg-gray-200 h-4 rounded w-1/2 mt-2" />
                <div className="bg-gray-200 h-8 rounded w-1/3 mt-3" />
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
// app/products/page.tsx
import { Suspense } from 'react';
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Products - My Store',
  description: 'Browse our curated collection',
};

export default function ProductsPage() {
  return (
    <div className="min-h-screen bg-gray-50">
      <div className="container mx-auto px-4 py-8">
        <h1 className="text-3xl font-bold">Products</h1>
        <p className="text-gray-600 mt-2">Browse our curated collection</p>
        <div className="mt-8">
          <Suspense fallback={<ProductGridSkeleton />}>
            <ProductGrid />
          </Suspense>
        </div>
        <div className="mt-12">
          <Suspense fallback={<CategoryNavSkeleton />}>
            <CategoryNav />
          </Suspense>
        </div>
      </div>
    </div>
  );
}
// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';

interface ProductPageProps {
  params: Promise<{ id: string }>;
}

export default async function ProductDetailPage({ params }: ProductPageProps) {
  const { id } = await params;
  const product = await getProduct(id);

  if (!product) {
    notFound();
  }

  return (
    <div className="min-h-screen bg-white">
      <div className="container mx-auto px-4 py-8">
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
          <ProductGallery images={product.images} />
          <ProductInfo product={product} />
        </div>
        <div className="mt-12">
          <Suspense fallback={<ReviewsSkeleton />}>
            <ProductReviews productId={product.id} />
          </Suspense>
        </div>
        <div className="mt-12">
          <Suspense fallback={<RelatedProductsSkeleton />}>
            <RelatedProducts category={product.category} excludeId={product.id} />
          </Suspense>
        </div>
      </div>
    </div>
  );
}

Pattern 3: Progressive Hydration with Dynamic Imports

// app/components/InteractiveMap.tsx
'use client';

import { useState, useEffect } from 'react';

interface MapProps {
  center: { lat: number; lng: number };
  markers: Array<{ lat: number; lng: number; label: string }>;
}

export function InteractiveMap({ center, markers }: MapProps) {
  const [MapLib, setMapLib] = useState<React.ComponentType<MapProps> | null>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      { rootMargin: '200px' }
    );

    const el = document.getElementById('map-container');
    if (el) observer.observe(el);

    return () => observer.disconnect();
  }, []);

  useEffect(() => {
    if (!isVisible) return;

    import('./MapRenderer').then((mod) => {
      setMapLib(() => mod.MapRenderer);
    });
  }, [isVisible]);

  if (!isVisible || !MapLib) {
    return (
      <div id="map-container" className="bg-gray-100 rounded-lg h-96 flex items-center justify-center">
        <p className="text-gray-400">Loading map...</p>
      </div>
    );
  }

  return <MapLib center={center} markers={markers} />;
}
// app/components/HeavyChart.tsx
import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('./ChartRenderer'), {
  loading: () => (
    <div className="bg-gray-100 rounded-lg h-80 animate-pulse flex items-center justify-center">
      <span className="text-gray-400">Loading chart...</span>
    </div>
  ),
  ssr: false,
});

export function HeavyChart({ data }: { data: ChartData[] }) {
  return (
    <div className="bg-white rounded-lg shadow-sm p-6">
      <h3 className="text-lg font-semibold mb-4">Data Analytics</h3>
      <Chart data={data} />
    </div>
  );
}
// app/components/CommentSection.tsx
import { Suspense } from 'react';

export function CommentSection({ postId }: { postId: string }) {
  return (
    <section className="mt-8">
      <h2 className="text-2xl font-bold mb-4">Comments</h2>
      <Suspense fallback={<CommentFormSkeleton />}>
        <CommentForm postId={postId} />
      </Suspense>
      <Suspense fallback={<CommentListSkeleton />}>
        <CommentList postId={postId} />
      </Suspense>
    </section>
  );
}

// Client interactive component: lazy loaded
'use client';
import { useState, useTransition } from 'react';

function CommentForm({ postId }: { postId: string }) {
  const [content, setContent] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    startTransition(async () => {
      await submitComment(postId, content);
      setContent('');
    });
  };

  return (
    <form onSubmit={handleSubmit} className="mb-6">
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        className="w-full border rounded-lg p-3 focus:ring-2 focus:ring-blue-500"
        rows={3}
        placeholder="Write your comment..."
      />
      <button
        type="submit"
        disabled={isPending || !content.trim()}
        className="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
      >
        {isPending ? 'Submitting...' : 'Post Comment'}
      </button>
    </form>
  );
}

Pattern 4: Parallel Data Fetching with Promise.all

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default async function DashboardPage() {
  return (
    <div className="min-h-screen bg-gray-50 p-6">
      <Suspense fallback={<OverviewSkeleton />}>
        <DashboardOverview />
      </Suspense>
    </div>
  );
}

async function DashboardOverview() {
  const [users, orders, revenue] = await Promise.all([
    fetchUsers(),
    fetchOrders(),
    fetchRevenue(),
  ]);

  return (
    <div>
      <div className="grid grid-cols-3 gap-6">
        <UserStats users={users} />
        <OrderStats orders={orders} />
        <RevenueStats revenue={revenue} />
      </div>
      <div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
        <Suspense fallback={<ChartSkeleton />}>
          <TrendChart data={revenue} />
        </Suspense>
        <Suspense fallback={<TableSkeleton />}>
          <RecentActivity orders={orders} />
        </Suspense>
      </div>
    </div>
  );
}
// lib/data-fetching.ts
import { cache } from 'react';

interface FetchOptions {
  revalidate?: number;
  tags?: string[];
}

function createFetchOptions(options?: FetchOptions): RequestInit {
  return {
    next: {
      revalidate: options?.revalidate ?? 60,
      tags: options?.tags ?? [],
    },
  };
}

export const fetchUsers = cache(async () => {
  const res = await fetch('https://api.example.com/users', createFetchOptions({
    revalidate: 300,
    tags: ['users'],
  }));
  if (!res.ok) throw new Error('Failed to fetch users');
  return res.json() as Promise<User[]>;
});

export const fetchOrders = cache(async () => {
  const res = await fetch('https://api.example.com/orders', createFetchOptions({
    revalidate: 60,
    tags: ['orders'],
  }));
  if (!res.ok) throw new Error('Failed to fetch orders');
  return res.json() as Promise<Order[]>;
});

export const fetchRevenue = cache(async () => {
  const res = await fetch('https://api.example.com/revenue', createFetchOptions({
    revalidate: 300,
    tags: ['revenue'],
  }));
  if (!res.ok) throw new Error('Failed to fetch revenue');
  return res.json() as Promise<RevenueData[]>;
});

export const fetchProduct = cache(async (id: string) => {
  const res = await fetch(`https://api.example.com/products/${id}`, createFetchOptions({
    revalidate: 600,
    tags: [`product-${id}`],
  }));
  if (!res.ok) return null;
  return res.json() as Promise<Product>;
});

export const fetchDashboardStats = cache(async () => {
  const res = await fetch('https://api.example.com/dashboard/stats', createFetchOptions({
    revalidate: 30,
    tags: ['dashboard', 'stats'],
  }));
  if (!res.ok) throw new Error('Failed to fetch dashboard stats');
  return res.json() as Promise<DashboardStats>;
});
// app/products/page.tsx — Parallel fetching + Suspense separation
import { Suspense } from 'react';

export default function ProductsPage() {
  return (
    <div>
      <Suspense fallback={<FeaturedProductsSkeleton />}>
        <FeaturedProducts />
      </Suspense>
      <Suspense fallback={<NewArrivalsSkeleton />}>
        <NewArrivals />
      </Suspense>
      <Suspense fallback={<SaleProductsSkeleton />}>
        <SaleProducts />
      </Suspense>
    </div>
  );
}

async function FeaturedProducts() {
  const products = await fetch('https://api.example.com/products/featured', {
    next: { revalidate: 600, tags: ['featured'] },
  }).then(r => r.json());
  return <ProductGrid products={products} />;
}

async function NewArrivals() {
  const products = await fetch('https://api.example.com/products/new', {
    next: { revalidate: 300, tags: ['new-arrivals'] },
  }).then(r => r.json());
  return <ProductGrid products={products} />;
}

async function SaleProducts() {
  const products = await fetch('https://api.example.com/products/sale', {
    next: { revalidate: 60, tags: ['sale'] },
  }).then(r => r.json());
  return <ProductGrid products={products} />;
}

Pattern 5: Production Streaming SSR with Error Boundaries

// app/components/ErrorBoundary.tsx
'use client';

import { Component, type ReactNode } from 'react';

interface ErrorBoundaryProps {
  fallback?: ReactNode;
  children: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
          <h3 className="text-red-800 font-semibold">Failed to load</h3>
          <p className="text-red-600 text-sm mt-2">{this.state.error?.message}</p>
          <button
            onClick={() => this.setState({ hasError: false, error: null })}
            className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg text-sm"
          >
            Retry
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}
// app/error.tsx — Route-level error boundary
'use client';

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    console.error('[Streaming SSR Error]', error.digest, error.message);
  }, [error]);

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="text-center">
        <h2 className="text-2xl font-bold text-gray-800">Page load error</h2>
        <p className="text-gray-600 mt-2">Error code: {error.digest || 'UNKNOWN'}</p>
        <button
          onClick={reset}
          className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-lg"
        >
          Reload
        </button>
      </div>
    </div>
  );
}
// app/page.tsx — Production-grade composition
import { Suspense } from 'react';
import { ErrorBoundary } from './components/ErrorBoundary';

export default function HomePage() {
  return (
    <div className="min-h-screen">
      <Header />
      <main>
        <HeroSection />
        <ErrorBoundary fallback={<ProductGridFallback />}>
          <Suspense fallback={<ProductGridSkeleton />}>
            <ProductGrid />
          </Suspense>
        </ErrorBoundary>
        <ErrorBoundary fallback={<RecommendationsFallback />}>
          <Suspense fallback={<RecommendationsSkeleton />}>
            <Recommendations />
          </Suspense>
        </ErrorBoundary>
        <ErrorBoundary fallback={<NewsletterFallback />}>
          <Suspense fallback={<NewsletterSkeleton />}>
            <Newsletter />
          </Suspense>
        </ErrorBoundary>
      </main>
      <Footer />
    </div>
  );
}
// app/components/fallbacks.tsx
export function ProductGridFallback() {
  return (
    <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
      <p className="text-yellow-700">Product data is temporarily unavailable. Please try again later.</p>
      <a href="/products" className="text-blue-600 underline mt-2 inline-block">
        Browse all products
      </a>
    </div>
  );
}

export function RecommendationsFallback() {
  return (
    <div className="text-center py-8 text-gray-500">
      <p>Personalized recommendations are temporarily unavailable</p>
    </div>
  );
}

export function NewsletterFallback() {
  return (
    <div className="text-center py-4 text-gray-400">
      <p>Newsletter subscription is temporarily unavailable</p>
    </div>
  );
}

Pitfall Guide

Pitfall 1: Suspense Boundary Granularity Too Fine

// ❌ Wrong: Each small component gets its own Suspense, causing multiple round trips
<Suspense fallback={<Skeleton />}><UserName /></Suspense>
<Suspense fallback={<Skeleton />}><UserEmail /></Suspense>
<Suspense fallback={<Skeleton />}><UserAvatar /></Suspense>
<Suspense fallback={<Skeleton />}><UserBio /></Suspense>
// 4 streaming round trips — TTFB is actually worse

// ✅ Correct: Related components share a Suspense boundary
<Suspense fallback={<UserProfileSkeleton />}>
  <UserProfile /> {/* Contains UserName, UserEmail, UserAvatar, UserBio */}
</Suspense>

Pitfall 2: Async Components Without Error Handling

// ❌ Wrong: async Server Component with no error handling
async function ProductList() {
  const products = await fetchProducts(); // If fetch fails, entire page returns 500
  return <div>{products.map(p => <Card key={p.id} />)}</div>;
}

// ✅ Correct: Combine with ErrorBoundary + Suspense
<ErrorBoundary fallback={<ProductListFallback />}>
  <Suspense fallback={<ProductListSkeleton />}>
    <ProductList />
  </Suspense>
</ErrorBoundary>

// ✅ Better: Internal try-catch in the component
async function ProductList() {
  try {
    const products = await fetchProducts();
    return <div>{products.map(p => <Card key={p.id} />)}</div>;
  } catch (error) {
    console.error('ProductList fetch failed:', error);
    return <ProductListFallback />;
  }
}

Pitfall 3: Double Skeleton from loading.tsx + Suspense

// ❌ Wrong: Both loading.tsx and Suspense fallback are active
// app/products/loading.tsx — shown during route transitions
export default function Loading() {
  return <FullPageSkeleton />;
}

// app/products/page.tsx — also has Suspense inside
export default function ProductsPage() {
  return (
    <Suspense fallback={<ProductGridSkeleton />}>
      <ProductGrid />
    </Suspense>
  );
}
// User sees: FullPageSkeleton → ProductGridSkeleton → Real content (double flash)

// ✅ Correct: Use loading.tsx only for route-level, Suspense inside pages
// Remove loading.tsx, use Suspense in layout.tsx instead
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <ProductsNav />
      <Suspense fallback={<ProductsPageSkeleton />}>
        {children}
      </Suspense>
    </div>
  );
}

Pitfall 4: Dynamic Import Without ssr: false Causes Hydration Errors

// ❌ Wrong: Component relying on browser APIs without disabling SSR
import dynamic from 'next/dynamic';

const Map = dynamic(() => import('./Map'), {
  loading: () => <div>Loading...</div>,
  // Missing ssr: false! Map uses window/document internally, SSR will error
});

// ✅ Correct: Disable SSR for browser-only components
const Map = dynamic(() => import('./Map'), {
  loading: () => <MapSkeleton />,
  ssr: false,
});

Pitfall 5: Promise.all One Failure Kills All

// ❌ Wrong: Promise.all — any one failure kills everything
const [users, orders, revenue] = await Promise.all([
  fetchUsers(),    // If this fails, orders and revenue are also lost
  fetchOrders(),
  fetchRevenue(),
]);

// ✅ Correct: Use Promise.allSettled + Suspense separation
const results = await Promise.allSettled([
  fetchUsers(),
  fetchOrders(),
  fetchRevenue(),
]);

const users = results[0].status === 'fulfilled' ? results[0].value : [];
const orders = results[1].status === 'fulfilled' ? results[1].value : [];
const revenue = results[2].status === 'fulfilled' ? results[2].value : [];

// ✅ Better: Each data source in its own Suspense boundary, fully isolated
<Suspense fallback={<UsersSkeleton />}>
  <UsersSection />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
  <OrdersSection />
</Suspense>
<Suspense fallback={<RevenueSkeleton />}>
  <RevenueSection />
</Suspense>

Error Troubleshooting

# Error Message Cause Solution
1 Hydration failed because the server rendered HTML didn't match Server and client rendered content mismatch Check Date/Random/browser APIs, use suppressHydrationWarning
2 Functions cannot be passed directly to Client Components Server Component passing function to Client Component Move logic into Client Component or use Server Actions
3 Route "/xxx" used cookies() but is statically generated Dynamic API called in static page Add export const dynamic = 'force-dynamic'
4 useSearchParams() should be wrapped in Suspense useSearchParams not wrapped in Suspense Add Suspense boundary around the component
5 Maximum call stack size exceeded Suspense nesting causing infinite loop Check if async component recursively references itself in fallback
6 Text content did not match Streaming HTML doesn't match hydration content Ensure Suspense fallback structure matches actual content
7 Cannot read properties of null (reading 'useContext') Client Hook used in Server Component Add 'use client' directive or split the component
8 Module not found: Can't resolve 'fs' Client component importing Node.js module Move Node.js logic to Server Component or Route Handler
9 Streaming chunk error: invalid byte sequence Encoding issue during streaming transfer Ensure API returns UTF-8, check proxy configuration
10 Abort signal timeout Data fetching timeout Increase timeout or use React.cache to avoid duplicate requests

Advanced Optimization

1. Partial Prerendering (PPR) for Static Shell + Dynamic Content

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    ppr: 'incremental',
  },
};

export default nextConfig;
// app/page.tsx — PPR mode
import { Suspense } from 'react';

export const experimental_ppr = true;

export default function HomePage() {
  return (
    <div className="min-h-screen">
      {/* Static shell — rendered at build time, CDN cached, TTFB ≈ 0 */}
      <Header />
      <HeroBanner />
      <StaticCategories />

      {/* Dynamic content — streamed at request time */}
      <Suspense fallback={<PersonalizedFeedSkeleton />}>
        <PersonalizedFeed />
      </Suspense>

      {/* Static content — rendered at build time */}
      <Footer />
    </div>
  );
}

async function PersonalizedFeed() {
  const user = await getCurrentUser();
  const feed = await fetchPersonalizedFeed(user.id);
  return <FeedGrid items={feed} />;
}

2. React Cache and Request Deduplication

// lib/cached-fetch.ts
import { cache } from 'react';

export const getProduct = cache(async (id: string): Promise<Product | null> => {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 600, tags: [`product-${id}`] },
  });
  if (!res.ok) return null;
  return res.json();
});

// Multiple components calling getProduct('123') — only one request is made
async function ProductHeader({ id }: { id: string }) {
  const product = await getProduct(id);
  if (!product) return null;
  return <h1 className="text-2xl font-bold">{product.name}</h1>;
}

async function ProductPrice({ id }: { id: string }) {
  const product = await getProduct(id);
  if (!product) return null;
  return <span className="text-lg text-red-600">${product.price}</span>;
}

async function ProductStock({ id }: { id: string }) {
  const product = await getProduct(id);
  if (!product) return null;
  return <span className={product.inStock ? 'text-green-600' : 'text-red-600'}>
    {product.inStock ? 'In Stock' : 'Out of Stock'}
  </span>;
}

3. Streaming Transfer Performance Monitoring

// app/components/StreamingReporter.tsx
'use client';

import { useEffect } from 'react';

interface StreamingMetrics {
  ttfb: number;
  firstChunk: number;
  lastChunk: number;
  totalChunks: number;
}

export function StreamingReporter() {
  useEffect(() => {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === 'navigation') {
          const nav = entry as PerformanceNavigationTiming;
          const metrics: StreamingMetrics = {
            ttfb: nav.responseStart - nav.requestStart,
            firstChunk: nav.responseStart - nav.fetchStart,
            lastChunk: nav.responseEnd - nav.fetchStart,
            totalChunks: Math.round(nav.transferSize / 16384),
          };

          if (metrics.ttfb > 1000) {
            console.warn('[Streaming SSR] Slow TTFB:', metrics);
          }

          if (typeof window !== 'undefined' && window.gtag) {
            window.gtag('event', 'streaming_ssr_metrics', {
              ttfb: metrics.ttfb,
              first_chunk: metrics.firstChunk,
              last_chunk: metrics.lastChunk,
            });
          }
        }
      }
    });

    observer.observe({ entryTypes: ['navigation'] });

    return () => observer.disconnect();
  }, []);

  return null;
}

Comparison

Dimension Traditional SSR Streaming SSR ISR CSR SSG
TTFB ⚠️ High (waits for slowest data) ✅ Low (static shell first) ✅ Very low (CDN) ⚠️ Medium (empty HTML) ✅ Very low (CDN)
First Paint ⚠️ All at once ✅ Progressive ✅ Instant ❌ Slow (waits for JS) ✅ Instant
Real-time Data ✅ Supported ✅ Supported ⚠️ Delayed ✅ Supported ❌ Not supported
SEO ✅ Full HTML ✅ Full HTML ✅ Full HTML ❌ Empty HTML ✅ Full HTML
Server Load ⚠️ High ⚠️ Medium ✅ Low ✅ Very low ✅ Very low
Error Isolation ❌ Full page failure ✅ Chunk-level degradation ❌ Full page failure ✅ Component-level ❌ Build failure
Cache Granularity ⚠️ Full page ✅ Per chunk ✅ Full page ⚠️ API-level ✅ Full page
Use Case Real-time dynamic pages Complex mixed pages Content sites Admin dashboards Static content sites

Summary: Streaming SSR isn't just a technical upgrade — it's a rendering paradigm shift from "wait for all data before responding" to "give users visible content first." The 2026 production path: Start with Suspense boundaries to split critical content blocks (TTFB -60%+) → then use loading.tsx for route-level fallbacks → progressive hydration via dynamic imports (INP -40%+) → parallel fetching with Promise.all + independent Suspense → production-grade fault tolerance with ErrorBoundary. Core principle: Stream instead of block, parallel instead of serial, degrade instead of 500.


Try these browser-local tools — no sign-up required →

#Next.js#Streaming SSR#React#Suspense#性能优化#2026#App Router