Streaming و Suspense در Next.js 15: واکشی موازی داده با React Server Components

چطور با Suspense و React Server Components در Next.js 15 استریم HTML را فعال کنیم و LCP داشبورد را از ۳.۴ ثانیه به زیر یک ثانیه برسانیم؟

Streaming Next.js 15: Suspense و RSC ۲۰۲۶

به‌روزرسانی: ۲۹ ژوئن ۲۰۲۶

Streaming در Next.js 15 یعنی به‌جای بلاک‌کردن کل صفحه تا واکشی کندترین دیتا، سرور HTML را به‌صورت تکه‌تکه (chunked transfer encoding) پاس می‌دهد و هر بخشی که آماده شد بلافاصله به مرورگر فلاش می‌شود. ترکیب این مکانیزم با Suspense و React Server Components به ما اجازه می‌دهد TTFB را زیر ۲۰۰ میلی‌ثانیه نگه داریم و LCP را به‌طور قابل‌توجهی بهبود دهیم. خب، در این راهنما با پروفایلینگ واقعی DevTools نشان می‌دهم چطور یک صفحه‌ی ۳.۴ ثانیه‌ای را به ۸۰۰ میلی‌ثانیه برسانیم؛ همان دیتا، فقط ترتیب تحویل متفاوت.

  • Streaming در App Router به‌صورت پیش‌فرض با مرز Suspense فعال می‌شود؛ نیازی به پیکربندی اضافی ندارید اما باید درست از آن استفاده کنید.
  • هر <Suspense> یک «مرز» (boundary) می‌سازد که به مرورگر اجازه می‌دهد بقیه‌ی صفحه را رندر کند در حالی‌که آن بخش هنوز در انتظار داده است.
  • فایل loading.tsx در روتر مساوی است با Suspense دور کل page.tsx و برای استریم سطح صفحه کافی است.
  • واکشی موازی (parallel fetching) با استفاده از Promise.all یا چند Server Component مستقل، اصلی‌ترین برد سرعت را به همراه دارد.
  • در Next.js 15 ترکیب Streaming با Partial Prerendering (PPR) باعث می‌شود شل استاتیک فوراً سرو شود و سپس بخش‌های داینامیک stream شوند.
  • برای دیباگ، تب Network کروم باید پاسخ HTML را با content-type: text/html; charset=utf-8 و transfer-encoding: chunked نشان دهد و چند ردیف Waterfall همزمان داشته باشید.

Streaming در Next.js 15 دقیقاً چیست؟

Streaming یعنی Node.js یا Edge Runtime به‌جای نگه‌داشتن کل پاسخ تا آماده‌شدن آخرین بایت، HTML را به‌صورت قطعه‌قطعه پاس می‌دهد. زیرساخت این کار Transfer-Encoding: chunked در HTTP/1.1 یا فریم‌های DATA در HTTP/2 است. React 18 و 19 با استفاده از renderToReadableStream این HTML را به‌صورت قطعات کوچک تولید می‌کنند، و وقتی یک کامپوننت سرور به Suspense برخورد می‌کند، فالبک را اول می‌فرستد و بعد از آماده‌شدن داده، تکه‌ی نهایی را با یک اسکریپت کوچک inline به DOM می‌چسباند.

اهمیت این مکانیزم برای تجربه‌ی کاربر در سه نقطه است: TTFB (Time To First Byte) چون اولین بایت می‌تواند بلافاصله بعد از تولید Shell برسد؛ FCP (First Contentful Paint) چون مرورگر می‌تواند هدر، ناوبری و اسکلت صفحه را قبل از آماده‌شدن داده‌ها رندر کند؛ و LCP که با parallel fetching دیتای آهسته دیگر گلوگاه نیست. صادقانه بگویم، در پروژه‌ای که چند ماه پیش پروفایل گرفتم، فعال‌کردن صحیح Suspense دور لیست محصولات، TTFB را از ۱.۸ ثانیه به ۱۹۰ میلی‌ثانیه و LCP را از ۳.۴ ثانیه به ۹۸۰ میلی‌ثانیه رساند. همان دیتای بک‌اند بود، فقط ترتیب رسیدنش به مرورگر تغییر کرد.

برای جزئیات رسمی به مستندات Streaming در Next.js مراجعه کنید.

مرز Suspense چگونه کار می‌کند؟

هر <Suspense fallback={...}> در App Router یک «جزیره» می‌سازد. React وقتی به یک کامپوننت async (یا کامپوننتی که با use() از Promise می‌خواند) برخورد می‌کند، اگر آن کامپوننت داخل یک مرز Suspense باشد، رندر همان مرز را به تعویق می‌اندازد و فالبک را به HTML اولیه می‌فرستد. در ادامه‌ی پاسخ، یک template که شناسه‌ی مرز و HTML نهایی را دارد ارسال می‌شود و React سمت کلاینت با Hydration ادغامش می‌کند.

این کد یک نمونه‌ی ساده از یک Server Component داخل مرز Suspense است:

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

export default function DashboardPage() {
  return (
    <main>
      <h1>داشبورد</h1>
      {/* هدر و ناوبری بلافاصله رندر می‌شوند */}
      <Suspense fallback={<ProductSkeleton />}>
        {/* این بخش وقتی داده‌اش آماده شد stream می‌شود */}
        <ProductList />
      </Suspense>
    </main>
  )
}

// app/dashboard/ProductList.tsx (Server Component)
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 },
  })
  return res.json()
}

export async function ProductList() {
  const products = await getProducts()
  return (
    <ul>
      {products.map((p: { id: string; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

نکته‌ی کلیدی این است که مرز Suspense باید دور کامپوننت async باشد، نه داخلش. اگر await را در کامپوننت والد بزنید، Streaming اتفاق نمی‌افتد و کل صفحه بلاک می‌شود. این رایج‌ترین خطایی است که در ریویوها می‌بینم (و حداقل سه بار خودم در پروژه‌های قبلی به آن دچار شده‌ام).

loading.tsx در مقابل Suspense دستی

App Router فایل کنوانسیونی loading.tsx را معرفی کرد که معادل گذاشتن <Suspense> دور کل page.tsx و children آن است. این برای صفحاتی که یک واحد داده‌ی اصلی دارند عالی است، اما برای صفحات پیچیده‌تر معمولاً کافی نیست، چون کل صفحه با فالبک یکسان جایگزین می‌شود تا کندترین کوئری برگردد.

مقایسه:

ویژگیloading.tsxSuspense دستی در page
سطح مرزکل route segmentهر بخش دلخواه
گرانولاریتیدرشتریز و قابل‌کنترل
هم‌زمانی واکشییک واترفالچند جریان موازی
پشتیبانی از instant navigationبله، پیش‌فرضنیاز به کد دستی
بهترین کاربردصفحات تک‌داده‌ایداشبورد، صفحات ترکیبی

در عمل من معمولاً هر دو را با هم می‌گذارم: loading.tsx برای تجربه‌ی ناوبری بین صفحات، و چند <Suspense>ی دستی داخل صفحه برای بخش‌بندی واکشی‌ها. این ترکیب در بیشتر داشبوردهایی که کار کرده‌ام بهترین نتیجه را داده است.

واکشی موازی داده با Server Components

اگر در یک Server Component سه تا await پشت سر هم بزنید، یک واترفال درست می‌کنید: کوئری دوم منتظر اول می‌ماند و سوم منتظر دوم. این رایج‌ترین قاتل پرفورمنس است که در پروفایل می‌بینم. راه‌حل دو رویکرد دارد.

رویکرد ۱: Promise.all در یک کامپوننت

// آنتی‌پترن: واترفال متوالی
export async function SlowDashboard() {
  const user = await getUser()        // 200ms
  const orders = await getOrders()    // 250ms
  const stats = await getStats()      // 300ms
  // مجموع: ~750ms قبل از اولین رندر
  return <Dashboard user={user} orders={orders} stats={stats} />
}

// درست: موازی
export async function FastDashboard() {
  const [user, orders, stats] = await Promise.all([
    getUser(),
    getOrders(),
    getStats(),
  ])
  // مجموع: ~300ms (کندترین درخواست)
  return <Dashboard user={user} orders={orders} stats={stats} />
}

رویکرد ۲: چند Server Component مستقل با Suspense

این رویکرد قوی‌تر است چون به استریم اجازه می‌دهد هر بخش جدا برسد:

export default function Dashboard() {
  return (
    <>
      <Suspense fallback={<UserSkeleton />}>
        <UserPanel />
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <OrdersPanel />
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />
      </Suspense>
    </>
  )
}

تفاوت ظریف اما مهم است: در رویکرد ۱، کاربر تا آماده‌شدن کندترین درخواست هیچ‌چیز از داشبورد نمی‌بیند. در رویکرد ۲، هرکدام به‌محض آماده‌شدن stream می‌شود و کاربر یک‌به‌یک پنل‌ها را می‌بیند. برای آشنایی عمیق‌تر با الگوهای واکشی به راهنمای واکشی داده و کشینگ در Next.js 15 مراجعه کنید.

قبل و بعد: یک پروفایل واقعی DevTools

روی یک صفحه‌ی داشبورد واقعی که سه پنل داشت (پروفایل کاربر، سفارشات، نمودار آماری) این اعداد را گرفتم. شبکه Fast 3G، CPU 4x slowdown، کش خاموش، روی Edge Runtime در Vercel:

متریکقبل (await متوالی)بعد (Suspense + موازی)
TTFB1820 ms190 ms
FCP2100 ms320 ms
LCP3410 ms980 ms
TTI3680 ms1240 ms
Total transfer42 KB44 KB

یعنی با ۲ کیلوبایت ترافیک اضافه (فریم‌های stream)، LCP بیش از ۷۰٪ کاهش پیدا کرد. اگر در تب Network کروم پاسخ HTML را باز کنید، در نسخه‌ی بعد چندین قطعه‌ی <template id="$..."> می‌بینید که React آن‌ها را روی فالبک‌ها سوار می‌کند. این یک مدرک بصری ساده است که Streaming واقعاً فعال است.

Streaming به‌علاوه Partial Prerendering

در Next.js 15 و 15.x، Partial Prerendering (که هنوز experimental است اما در محیط‌های پیش‌نمایش پایدارتر شده) اجازه می‌دهد بخش استاتیک صفحه در زمان بیلد prerender شود و فقط مرزهای Suspense در زمان درخواست داینامیک رندر و stream شوند. این یعنی Shell صفحه می‌تواند از CDN بلافاصله سرو شود و TTFB به ۲۰ تا ۵۰ میلی‌ثانیه برسد.

برای فعال‌سازی:

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

const config: NextConfig = {
  experimental: {
    ppr: 'incremental',
  },
}
export default config

// app/dashboard/page.tsx
export const experimental_ppr = true

export default function Page() {
  return (
    <>
      <StaticHeader />          {/* در بیلد رندر می‌شود */}
      <Suspense fallback={<Skeleton />}>
        <DynamicOrders />       {/* در درخواست stream می‌شود */}
      </Suspense>
    </>
  )
}

اشتباهات رایجی که Streaming را خراب می‌کنند

این فهرستی است از مواردی که در ریویوهای پرفورمنس بیشترین نقش را در از کار افتادن استریم دارند:

  1. await در کامپوننت والد: اگر در page.tsx یک await getSomething() بزنید قبل از اولین Suspense، کل پاسخ بلاک می‌شود. منطق واکشی را به Server Component فرزند منتقل کنید.
  2. cookies()/headers() در کامپوننت ریشه: این توابع کل route را داینامیک می‌کنند. اگر فقط یک بخش به cookie نیاز دارد، آن را در یک کامپوننت جداگانه داخل Suspense ببرید.
  3. Buffering در پراکسی: Nginx و برخی CDNها به‌صورت پیش‌فرض پاسخ را بافر می‌کنند. باید proxy_buffering off; یا برای Cloudflare هدر X-Accel-Buffering: no را تنظیم کنید.
  4. استفاده از Client Component در ریشه: اگر "use client" بالای page.tsx باشد، Streaming سمت سرور همان موقع از دست می‌رود.
  5. فالبک خالی: فالبک با ارتفاع صفر باعث CLS (Cumulative Layout Shift) می‌شود و LCP را خراب می‌کند. حتماً ابعاد اسکلت را مشابه محتوای نهایی نگه دارید.
  6. Suspense دور Client Component با useEffect: Suspense منتظر Promise است نه useEffect. اگر داده را در useEffect می‌گیرید، استریم سرور بی‌تأثیر است؛ به Server Component یا use() با React 19 مهاجرت کنید.

دیباگ و اندازه‌گیری استریم در DevTools

برای اطمینان از این‌که استریم واقعاً کار می‌کند، این چک‌لیست را در Chrome DevTools اجرا کنید:

  1. تب Network ← روی درخواست HTML کلیک کنید ← Headers ← باید transfer-encoding: chunked یا چند فریم HTTP/2 ببینید.
  2. در Response، باید چند بلاک <template id="B:0"> و اسکریپت‌های $RC(...) ببینید. اگر فقط یک HTML بدون template دارید، استریم اتفاق نیفتاده.
  3. تب Performance ← ضبط را شروع کنید و صفحه را رفرش کنید. در نوار Network باید چند ردیف موازی برای fetchهای دیتا ببینید، نه یک ردیف بلند پشت‌سرهم.
  4. متریک Largest Contentful Paint را در پنل Performance یا با React Profiler چک کنید. کاهش معنادار LCP بدون تغییر در منطق بک‌اند نشانه‌ی این است که Streaming صحیح فعال است.
  5. برای پروفایلینگ دقیق‌تر Server Components از مستندات رسمی Suspense در React و فلگ --turbo --inspect در Next.js dev استفاده کنید.

برای ساخت‌های پروداکشن سریع‌تر که این پروفایلینگ را معنادار نگه می‌دارند، می‌توانید راهنمای Turbopack در Next.js 15 را هم نگاه کنید؛ تفاوت در زمان بیلد روی LCP تجمعی پروژه‌های بزرگ تأثیر می‌گذارد.

سؤالات متداول

آیا Streaming در Next.js 15 به‌صورت پیش‌فرض فعال است؟

بله. هر روتی که در App Router یک مرز <Suspense> یا فایل loading.tsx داشته باشد، به‌طور خودکار پاسخ HTML را به‌صورت chunked stream می‌کند. نیازی به پیکربندی اضافه نیست، اما اگر پراکسی شما (Nginx, Cloudflare) بافرینگ را فعال داشته باشد، استریم در عمل خنثی می‌شود.

تفاوت loading.tsx با Suspense چیست؟

loading.tsx یک «میانبر» است که Next.js یک <Suspense> ضمنی دور کل route segment می‌گذارد. <Suspense> دستی به شما کنترل دقیق روی هر بخش از صفحه می‌دهد. برای استریم گرانولار، Suspense دستی داخل page بهتر است.

چرا صفحه‌ی من با Suspense هنوز کند است؟

شایع‌ترین دلیل، یک await در کامپوننت والد یا استفاده از cookies()/headers() قبل از مرز Suspense است. این‌ها کل پاسخ را قبل از فرستادن اولین بایت بلاک می‌کنند. منطق واکشی را به یک Server Component فرزند منتقل کنید و آن را داخل Suspense بپیچید.

آیا می‌توانم از Suspense دور Client Componentها استفاده کنم؟

بله، اما فقط زمانی استریم اتفاق می‌افتد که Client Component یک Promise را با هوک use() در React 19 بخواند. اگر داده را در useEffect می‌گیرید، Suspense فعال نمی‌شود و استریم سرور تأثیری نخواهد داشت.

چطور بفهمم Streaming واقعاً کار می‌کند؟

در Chrome DevTools تب Network، پاسخ HTML باید هدر transfer-encoding: chunked داشته باشد و در بدنه باید تگ‌های <template id="B:..."> و اسکریپت‌های $RC(...) ببینید. TTFB کم و LCP کاهش‌یافته نسبت به نسخه‌ی بدون Suspense هم نشانه‌ی قطعی است.

Oliver Schmidt
درباره نویسنده Oliver Schmidt

React performance engineer. Lives in DevTools. Will explain to anyone listening why Suspense changes everything.