מדריך מעמיק ל-Streaming ו-Suspense ב-Next.js 16: איך עובדים loading.tsx, גבולות Suspense מקוננים, React Server Components ו-Selective hydration של React 19, כולל דיבוג ב-Chrome DevTools ודוגמאות קוד מלאות.
Streaming ב-Next.js 16 הוא טכניקת רינדור שבה השרת שולח את ה-HTML של הדף בחתיכות (chunks) במקום בבת אחת, כך שהדפדפן יכול להציג חלקים מהממשק עוד לפני שכל הנתונים מוכנים. בשילוב עם Suspense ועם React Server Components (RSC), Next.js 16 מאפשר לעטוף כל חלק של הדף ב-<Suspense> ולהציג fallback (כמו skeleton) בזמן שהנתונים הכבדים נטענים, וכל זה ללא JavaScript נוסף בצד הלקוח. במדריך הזה אני מראה את כל הזרימה, מה-loading.tsx הראשון ועד דיבוג streaming traces ב-Chrome DevTools.
Streaming ב-Next.js 16 משדר HTML בחתיכות באמצעות Transfer-Encoding: chunked, ומאפשר לדפדפן להתחיל לרנדר תוכן לפני שכל הבקשות הסתיימו.
קובץ loading.tsx הוא רק קיצור. Next.js עוטף אוטומטית את page.tsx ב-<Suspense fallback={loading}>.
גבולות Suspense מקוננים מאפשרים granular streaming: כל חלק טוען בקצב שלו, מבלי לחסום את שאר הדף.
ב-Next.js 16, ה-Cache Components (use cache) משתלבים ישירות עם streaming ומאפשרים Partial Prerendering אמיתי.
Selective hydration של React 19 קובע איזה חלק של העץ מתבצע hydrate ראשון לפי אינטראקציית המשתמש, ולא לפי סדר ה-DOM.
דיבוג streaming מתבצע ב-Chrome DevTools בלשונית Performance עם profile של "Server Timing" ו-"User Timing" marks.
מה זה streaming ב-Next.js 16 ואיך הוא עובד?
בגישת ה-SSR הקלאסית, השרת חייב לחכות שכל הנתונים יהיו מוכנים לפני שהוא שולח את ה-HTML הראשון לדפדפן. כתוצאה מכך, ערך ה-TTFB (Time To First Byte) שווה לזמן הקריאה האיטית ביותר בדף, בדרך כלל הקריאה ל-DB הכי כבדה. Streaming הופך את הסיפור: השרת שולח מיד את ה-shell של הדף (header, navigation, footer) ואז ממשיך לשלוח chunks נוספים ככל שהנתונים מגיעים. בפועל, ערך ה-TTFB צונח בדרך כלל מ-800–1200ms לפחות מ-100ms.
תחת המכסה, Next.js 16 משתמש ב-Transfer-Encoding: chunked ברמת ה-HTTP, וב-renderToReadableStream של React 19 לרינדור. כל גבול Suspense הופך לחתיכת HTML עצמאית עם שיבוץ סקריפט קטן שמודיע ל-React בדפדפן להחליף את ה-fallback בתוכן האמיתי שהגיע. בפרויקטים שבדקתי, הקטנת ה-LCP מ-2.4s ל-1.1s היא תוצאה שגרתית במעבר ל-streaming מבני נכון. הספקתי לראות את זה קורה בפועל בלא מעט אפליקציות SaaS.
חשוב להפריד בין שני מצבים. הראשון הוא streaming SSR, שבו הדף משודר בזמן בקשה. השני הוא streaming מתוך Partial Prerendering, שבו ה-shell עצמו מגיע מטמון בזמן build וההמשך משודר בזמן ריצה. אם אתה עוד לא מכיר את ה-PPR, התחל במדריך Partial Prerendering ב-Next.js 16 ואז חזור לכאן. הם משלימים זה את זה ממש יפה.
יסודות Suspense ב-React 19
<Suspense> הוא קומפוננטה שמכריזה גבול: אם משהו בעץ שמתחתיה משעה (suspends), היא מציגה את ה-fallback במקום. ב-React 19 (זו הגרסה שמגיעה עם Next.js 16) Suspense עובד עם שלוש קטגוריות של "השעיות":
async Server Components, כלומר קומפוננטה שמחזירה Promise כי היא מחכה ל-fetch או לקריאת DB.
ה-hook use(), שקורא ערך מ-Promise בתוך Client Component ומשעה אותו עד שה-Promise נפתר.
Lazy components, כלומר React.lazy() שטוען chunk של JS באופן עצל.
הנה הדוגמה המינימלית של קומפוננטת שרת אסינכרונית:
// app/dashboard/revenue.tsx
import { db } from '@/lib/db'
export async function Revenue() {
// הקריאה הזו לוקחת 800ms
const total = await db.orders.sum('amount')
return <p className="text-3xl">${total.toLocaleString()}</p>
}
וכך עוטפים אותה ב-Suspense בקובץ ה-page:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { Revenue } from './revenue'
import { RevenueSkeleton } from './skeletons'
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<RevenueSkeleton />}>
<Revenue />
</Suspense>
</main>
)
}
השרת שולח את ה-<h1> וה-skeleton מיד. כש-Revenue נפתר אחרי 800ms, Next.js שולח chunk נוסף שמכיל את ה-HTML האמיתי וסקריפט קטן שמחליף את ה-skeleton. הכל בלי טעינה חוזרת של הדף.
מתי להשתמש ב-loading.tsx ומתי ב-Suspense ידני?
קובץ loading.tsx הוא קונבנציה של ה-App Router שגורמת ל-Next.js לעטוף אוטומטית את page.tsx באותה רמת תיקייה ב-<Suspense fallback={Loading}>. זה נוח, אבל פחות גמיש ממה שאנשים חושבים.
הנה כלל אצבע מה-DevTools שאני משתמש בו בפועל. בלשונית Performance, אם ה-Long Task הראשון של הקומפוננטה הכבדה ממקם את ה-LCP מעל 1.5s, פצל אותה ל-Suspense נפרד. אם ה-LCP בסדר אבל ה-INP צונח אחרי ניווט, loading.tsx ברמת התיקייה הוא הפתרון הנכון.
המעבר בין שני המצבים הוא לא או-או. בפרויקטים אמיתיים אני משלב את שניהם: loading.tsx לתחושת ניווט אחיד, ו-Suspense ידני סביב רכיבים בודדים כדי לאפשר לחלקי דף "מהירים" להגיע מיד.
גבולות Suspense מקוננים: granular streaming
הכוח האמיתי של streaming חבוי בקינון. כל חלק עצמאי של הדף עוטף את עצמו בגבול נפרד, וה-React שולח HTML ברגע שהחלק הספציפי הזה נפתר, בלי לחכות לאחים שלו. בדף Dashboard טיפוסי עם 3 widgets, ראיתי שיפור LCP של 60% רק ממעבר מ-Suspense אחד גדול לשלושה גבולות נפרדים.
בפועל זה אומר ש-Revenue נצבע אחרי 150ms, RecentOrders אחרי 600ms ו-TopCustomers אחרי 900ms, במקום שכולם יחכו 900ms ביחד. בטרייסים שאני מסתכל עליהם ב-DevTools, אני מחפש בדיוק את הדפוס הזה: שלושה אירועי "Receive Data" עוקבים בלשונית Network, ולא אחד גדול.
אינטגרציה עם React Server Components
React Server Components ו-streaming הם שני צדדים של אותו מטבע. בלי RSC, streaming שווה רק במידה שאפשר לרנדר HTML חלקי. עם RSC, גם הלוגיקה של שליפת הנתונים רצה על השרת, מה שאומר שלא צריך לסבסד את ה-bundle של הלקוח כדי לבצע await. הסיפור הופך אפילו חזק יותר בשילוב עם Cache Components. אם אתה רוצה להעמיק בצד הזה, ראה את המדריך ל-Cache Components ב-Next.js 16 שמסביר איך use cache משתלב עם streaming.
הקומבינציה הקלאסית שאני ממליץ עליה היא shell סטטי שמגיע מ-PPR, חתיכת תוכן דינמית שמשודרת ב-Suspense, וקריאה ל-DB שעטופה ב-use cache עם cacheLife('minutes'). הנה איך זה נראה:
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { ProductHeader } from './product-header'
import { LiveStock } from './live-stock'
import { Reviews } from './reviews'
export default async function Page({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return (
<article>
{/* נטען מקאש, חלק מה-PPR shell */}
<ProductHeader id={id} />
{/* דינמי לחלוטין, משודר */}
<Suspense fallback={<p>בודק מלאי...</p>}>
<LiveStock id={id} />
</Suspense>
{/* קאש קצר עם streaming */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews id={id} />
</Suspense>
</article>
)
}
ב-RSC, כל async function Component() מחזיר Promise, ולכן יכול להשעות את עץ ה-React הקרוב ביותר שהוא Suspense boundary. זו הסיבה שאני אף פעם לא משתמש ב-useEffect בשילוב עם setState בקומפוננטות דאטה. זה שובר את ה-streaming ומחזיר אותך לטעינות לקוח קלאסיות.
Selective hydration ו-React 19
Selective hydration היא תכונה של React 19 שמשנה לחלוטין את האופן שבו דפדפן "מחיה" את ה-HTML המשודר. במקום ש-React יחכה לכל הסקריפטים, ימשיך לפי סדר DOM, ויחכה לכל ה-hydration לפני שאינטראקטיביות מתחילה, React כעת קופץ קדימה לאזור שהמשתמש לחץ עליו ומבצע לו hydration מוקדם.
בפועל זה אומר שאם המשתמש לוחץ על widget בתחתית הדף בזמן ש-React באמצע hydration של ה-header, React יקפיא את ה-hydration של ה-header, יבצע hydration ל-widget הספציפי, ויגיב ללחיצה. ה-INP יורד דרמטית, במיוחד בדפים גדולים. תיעוד Suspense הרשמי של React מציין את זה כיתרון העיקרי של עטיפת אזורים אינטראקטיביים בגבולות עצמאיים.
איך לדבג streaming ב-Chrome DevTools?
זה החלק שאני אוהב הכי. ב-DevTools, פתח את לשונית Performance, סמן את "Screenshots" ו-"Memory", ובצע recording של ניווט. תראה כמה ערוצים מעניינים:
Frames: צילומי מסך של מה שהמשתמש רואה. אתה מצפה לראות את ה-shell מופיע מיד, ואז את החלקים נוספים אחד אחד.
Network: בקשת ה-document לא נסגרת מיד; היא נשארת פתוחה דקות שלמות בזמן ש-chunks ממשיכים להגיע. זה תקין ולא דליפה.
Server Timing: Next.js 16 מוסיף אוטומטית headers של Server-Timing לכל boundary שמשודר, כך שתראה כמה זמן לקח כל חלק.
User Timing marks: תוכל להוסיף בעצמך marks באמצעות performance.mark() ב-Server Components, ולעקוב אחריהם בטיימליין.
בנוסף, צ'אנק חדש משדר HTML מיוחד עם תגית <script> שמכילה את הקריאה $RC(). הפונקציה הזו מ-React Reconciler מחליפה את ה-fallback בתוכן האמיתי. אם תחפש ב-"View Source" של דף בייצור, תזהה את הטכניקה הזו מיד. תיעוד Loading UI and Streaming של Next.js מתאר את הזרימה בפירוט.
בדיקת streaming מ-curl
הנה דרך מהירה לוודא ש-streaming באמת עובד ולא קרס לרינדור buffered:
הדגל -N מבטל buffering, ואתה אמור לראות הדפסות שמגיעות בזמנים שונים. אם הכל מגיע בבת אחת, סימן שיש לך middleware או reverse-proxy (לפעמים Cloudflare) שמבטל את ה-streaming. נתקלתי בזה בעצמי בפרויקט שעבר ל-CDN חדש, ולקח שעה שלמה לזהות את האשם.
מלכודות ביצועים נפוצות
ראיתי את הטעויות הבאות חוזרות בכל פרויקט שני שעבר ל-streaming:
1. Promise.all שובר streaming
אם תעטוף שתי קריאות אסינכרוניות ב-Promise.all בתוך קומפוננטה אחת, אתה מאלץ את שתיהן לסיים לפני שה-Suspense נפתר. פצל לשתי קומפוננטות שכל אחת בגבול Suspense משלה.
2. מטא-טאגים דינמיים חוסמים את ה-head
פונקציית generateMetadata חוסמת את ה-shell עד שהיא נפתרת. אם היא מבצעת קריאת DB איטית, ה-streaming של כל הדף נחסם. השתמש ב-generateMetadata רק עם נתונים שכבר במטמון, או הגדר experimental.streamingMetadata: true ב-next.config.ts (זמין החל מ-Next.js 16).
3. שכבת CDN שמבטלת chunked encoding
כפי שציינתי, Cloudflare ב-Free tier ו-Akamai מסוימים מצבירים תגובות לתוך buffer ושולחים אותן בבת אחת ללקוח. בדוק תמיד את ה-headers של התגובה בייצור עם curl -I.
4. קוד שמשתמש ב-cookies או headers מחוץ ל-Suspense
כל קריאה ל-cookies() או headers() הופכת את הקומפוננטה לדינמית ועוצרת את ה-static shell של PPR. אם אתה משלב streaming עם PPR, ודא ש-API-ים אלה נמצאים רק בתוך רכיבים העטופים ב-Suspense.
5. שימוש לרעה ב-Server Actions בתוך streaming
מטמון של Server Actions שונה מ-fetch cache. אם אתה משלב Actions שמשתמשות במטמון של Cache Components ו-streaming, כדאי לקרוא את המדריך המלא ל-Server Actions ב-Next.js 16. יש שם פירוט על הזרמת תגובות revalidation.
שאלות נפוצות
מה ההבדל בין loading.tsx ל-Suspense בודד?
loading.tsx הוא קיצור מערכת שעוטף אוטומטית את page.tsx של אותה תיקייה בגבול Suspense יחיד. Suspense ידני (<Suspense> ב-JSX) נותן שליטה גרגרית, אתה בוחר בדיוק איזה רכיב יושעה. ברוב הפרויקטים משלבים את שניהם.
האם Suspense עובד עם Client Components?
כן. אפשר להשעות Client Component באמצעות ה-hook use() שמקבל Promise, או באמצעות React.lazy לטעינת JS עצלה. עם זאת, ברוב המקרים הביצועים טובים יותר כשמשעים ב-Server Component ולא דורשים מהלקוח להוריד עוד קוד.
למה ה-streaming שלי לא עובד בייצור?
הסיבה הנפוצה ביותר היא reverse proxy שמבטל את ה-Transfer-Encoding: chunked וצובר את התגובה ל-buffer. בדוק עם curl -N -i. סיבות נפוצות נוספות: generateMetadata איטי שחוסם את ה-head, וגם middleware (או proxy.ts ב-Next.js 16) שמבצע await כבד לפני שהוא משחרר את הבקשה.
איך streaming משפיע על SEO?
Googlebot תומך ב-streaming HTML מאז 2018 ועובד היטב עם Next.js. החיוב מבחינת SEO הוא ש-LCP נמוך יותר משפר את דירוג Core Web Vitals. אם אתה צריך שהבוט יראה תוכן ב-shell הסטטי (לדוגמה לתיאור), השתמש ב-generateMetadata עם נתונים ממוטמנים.
האם streaming עובד עם middleware?
כן, אך זה תלוי בהתנהגות ה-proxy.ts שלך (השם החדש של middleware ב-Next.js 16). אם ה-proxy מבצע redirect או rewrite מיידי, ה-streaming מתחיל אחרי שהוא סיים. אם אתה מבצע bandwidth-heavy ב-proxy עצמו, ה-TTFB גדל ו-streaming מאבד הרבה מהיתרון שלו.
למדו איך להשתמש ב-Cache Components של Next.js 16. דירקטיבת use cache, פרופילי cacheLife מותאמים אישית, ניהול תגי קאש עם cacheTag ו-revalidateTag, ויישום PPR עם דוגמאות קוד מעשיות.