Next.js 16 Streaming + Suspense: คู่มือ Progressive Rendering พร้อมโค้ดตัวอย่าง

เรียนรู้ Streaming และ Suspense ใน Next.js 16 — ตั้งแต่ loading.js vs Suspense, 5 รูปแบบ Streaming พร้อมโค้ด, Parallel Data Fetching, PPR, use cache และ benchmark จริง

บทนำ: ทำไม Streaming ถึงเปลี่ยนเกมการ Render หน้าเว็บ

ลองนึกภาพตามนะครับ — ผู้ใช้เปิดหน้า Dashboard ของแอปคุณขึ้นมา มี 5 ส่วนหลัก: โปรไฟล์ผู้ใช้, กราฟยอดขาย, ฟีดกิจกรรม, การแจ้งเตือน, และรายการล่าสุด แต่ละส่วนดึงข้อมูลจาก API คนละตัว บางตัวตอบไว 50ms บางตัวช้าถึง 2 วินาที

ถ้าเป็น SSR แบบเดิม? ผู้ใช้จะเห็น จอว่างเปล่า จนกว่าทุกข้อมูลจะโหลดครบ ซึ่งอาจนานถึง 2 วินาทีเต็มเลย

แต่ถ้าเราใช้ Streaming ล่ะ? ผู้ใช้จะเห็นโครงหน้าเว็บ (shell) ทันทีใน 50ms ส่วนที่โหลดเร็วจะโผล่ก่อน ส่วนที่ช้ากว่าก็ค่อยๆ stream เข้ามาทีหลัง — ทั้งหมดนี้ ไม่ต้องรอกัน พูดตรงๆ ว่ามันเปลี่ยน UX ไปเลย

นี่คือหัวใจของ Streaming ใน Next.js 16 ครับ เมื่อรวมกับ <Suspense> ของ React 19 มันกลายเป็นเครื่องมือที่ทรงพลังมากสำหรับ Progressive Rendering — ผู้ใช้จะรู้สึกว่าแอปตอบสนองไวเสมอ แม้ข้อมูลจะยังโหลดไม่เสร็จก็ตาม

ในบทความนี้ ผมจะพาคุณดูทุกแง่มุมของ Streaming ใน Next.js 16 ตั้งแต่วิธีทำงานเบื้องหลัง, ความต่างระหว่าง loading.js กับ <Suspense>, รูปแบบการใช้งานจริง 5 แบบพร้อมโค้ด, การผสานกับ PPR และ use cache ไปจนถึง benchmark จริงและ best practices ที่ควรรู้

Streaming SSR ทำงานอย่างไรใน Next.js 16

Streaming คือเทคนิคที่แบ่ง route ออกเป็น "chunks" ย่อยๆ แล้วค่อยๆ ส่งจากเซิร์ฟเวอร์ไปยังเบราว์เซอร์ทันทีที่แต่ละส่วนพร้อม โดยใช้ HTTP chunked transfer encoding เป็นกลไกเบื้องหลัง

SSR แบบเดิม vs Streaming

ใน SSR แบบเดิม กระบวนการเป็นแบบ sequential ทั้งหมด:

  1. เซิร์ฟเวอร์รับ request
  2. ดึงข้อมูล ทั้งหมด ให้เสร็จก่อน
  3. Render HTML ทั้งหน้า
  4. ส่ง HTML กลับไปยังไคลเอนต์ ทีเดียว
  5. ไคลเอนต์ hydrate เพื่อให้ interactive ได้

แต่ Streaming SSR ทำงานต่างออกไปเลย:

  1. เซิร์ฟเวอร์รับ request
  2. ส่ง HTML shell (โครง) กลับ ทันที — พร้อม fallback placeholder สำหรับส่วนที่ยังไม่พร้อม
  3. เริ่มดึงข้อมูลแต่ละส่วน พร้อมกัน
  4. เมื่อส่วนไหนพร้อม ก็ stream HTML chunk นั้นไปแทนที่ placeholder
  5. ไคลเอนต์ hydrate แต่ละส่วนทันทีที่ได้รับ

ผลลัพธ์คือ Time to First Byte (TTFB) เร็วขึ้นอย่างเห็นได้ชัด เพราะเซิร์ฟเวอร์ไม่ต้องนั่งรอทุกอย่างเสร็จก่อนค่อยส่ง response กลับ

กลไกหลัก: React Suspense

หัวใจของ Streaming ใน Next.js 16 คือ <Suspense> ของ React 19 วิธีใช้ก็ไม่ซับซ้อนเลย — แค่ครอบ async Server Component ด้วย <Suspense> แล้วกำหนด fallback UI:

import { Suspense } from 'react'

async function SlowComponent() {
  const data = await fetchSlowAPI() // ใช้เวลา 2 วินาที
  return <div>{data.title}</div>
}

export default function Page() {
  return (
    <div>
      <h1>หน้าแรก</h1> {/* แสดงทันที */}
      <Suspense fallback={<p>กำลังโหลด...</p>}>
        <SlowComponent /> {/* stream เข้ามาเมื่อพร้อม */}
      </Suspense>
    </div>
  )
}

Next.js จะส่ง <h1>หน้าแรก</h1> และ fallback กำลังโหลด... ไปยังเบราว์เซอร์ทันที จากนั้นเมื่อ fetchSlowAPI() เสร็จ ก็จะ stream HTML ของ SlowComponent เข้ามาแทนที่ fallback — โดยไม่ต้อง reload หน้าเลย

loading.js vs Suspense: เลือกใช้อะไรเมื่อไหร่

Next.js 16 มีสองวิธีในการสร้าง loading state สำหรับ Streaming ซึ่งตรงนี้คนเพิ่งเริ่มใช้อาจสับสนนิดหน่อย — มีทั้ง loading.js (route-level) และ <Suspense> (component-level) มาดูกันว่าเลือกใช้ยังไง

วิธีที่ 1: loading.tsx (Route-Level)

สร้างไฟล์ loading.tsx ใน route segment ที่ต้องการ:

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
      <div className="h-64 bg-gray-200 rounded"></div>
    </div>
  )
}

เบื้องหลัง Next.js จะซ้อน loading.js ไว้ใน layout.js แล้วครอบ page.js ด้วย <Suspense> ให้อัตโนมัติ ข้อดีที่ชัดเจนคือ fallback UI จะถูก prefetch ทำให้ตอน navigate ไปหน้านี้มันรู้สึก instant เลย

วิธีที่ 2: <Suspense> (Component-Level)

// app/dashboard/page.tsx
import { Suspense } from 'react'
import SalesChart from '@/components/SalesChart'
import RecentOrders from '@/components/RecentOrders'

export default function DashboardPage() {
  return (
    <div>
      <h1>แดชบอร์ด</h1>
      <div className="grid grid-cols-2 gap-6">
        <Suspense fallback={<ChartSkeleton />}>
          <SalesChart />
        </Suspense>
        <Suspense fallback={<OrdersSkeleton />}>
          <RecentOrders />
        </Suspense>
      </div>
    </div>
  )
}

<h1>แดชบอร์ด</h1> แสดงทันที ส่วน SalesChart กับ RecentOrders จะ stream เข้ามาแยกกันเมื่อข้อมูลพร้อม ซึ่งอันนี้ผมว่าเป็นวิธีที่ flexible กว่ามากในหลายๆ สถานการณ์

ตารางเปรียบเทียบ

คุณสมบัติloading.js<Suspense>
ขอบเขตทั้ง route segmentเลือกได้ระดับ component
การตั้งค่าอัตโนมัติ (file-based)ต้องเขียนเอง (ใน JSX)
ความละเอียดหยาบ (ทั้งหน้า)ละเอียด (แต่ละส่วน)
Re-trigger ด้วย keyไม่ได้ได้ — <Suspense key={param}>
Prefetchใช่ (อัตโนมัติ)ไม่

ข้อควรระวัง: loading.js กับ Layout ที่เข้าถึง Runtime Data

อันนี้เป็นกับดักที่นักพัฒนาเยอะมากเจอ (รวมถึงผมด้วย) — ถ้า layout.js ใน segment เดียวกันกับ loading.js มีการเรียก cookies(), headers() หรือ fetch ที่ไม่ได้ cache, loading.js จะไม่แสดง fallback จนกว่า layout จะ render เสร็จก่อน:

// app/dashboard/layout.tsx — ⚠️ ปัญหา!
import { cookies } from 'next/headers'

export default async function DashboardLayout({ children }) {
  const cookieStore = await cookies()
  const theme = cookieStore.get('theme')?.value
  
  return (
    <div data-theme={theme}>
      <Sidebar />
      {children} {/* loading.js จะไม่แสดงจนกว่า cookies() เสร็จ */}
    </div>
  )
}

วิธีแก้: ย้ายการเข้าถึง runtime data ไปอยู่ใน Suspense boundary ของมันเอง:

// app/dashboard/layout.tsx — ✅ แก้ไขแล้ว
import { Suspense } from 'react'

export default function DashboardLayout({ children }) {
  return (
    <div>
      <Suspense fallback={<SidebarSkeleton />}>
        <ThemedSidebar /> {/* ย้าย cookies() มาใน component นี้ */}
      </Suspense>
      {children}
    </div>
  )
}

5 รูปแบบ Streaming ที่ต้องรู้ (พร้อมโค้ดตัวอย่าง)

เอาล่ะ มาเข้าเรื่องจริงจังกัน ส่วนนี้คือ 5 รูปแบบที่ผมว่าทุกคนที่ใช้ Next.js 16 ควรรู้ ผมจะให้โค้ดตัวอย่างครบทุกแบบเลย

รูปแบบที่ 1: Independent Suspense Boundaries (แนะนำมากที่สุด)

นี่คือรูปแบบที่ทรงพลังที่สุดและควรใช้เป็นค่าเริ่มต้น แต่ละส่วนของหน้ามี Suspense boundary ของตัวเอง โหลดอิสระจากกันโดยสิ้นเชิง:

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

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-3 gap-6">
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile />         {/* API: 100ms */}
      </Suspense>
      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />        {/* API: 800ms */}
      </Suspense>
      <Suspense fallback={<MetricsSkeleton />}>
        <MetricsSidebar />      {/* API: 2000ms */}
      </Suspense>
    </div>
  )
}

// แต่ละ component เป็น async Server Component
async function UserProfile() {
  const user = await fetchUser()
  return <div>{user.name}</div>
}

async function ActivityFeed() {
  const feed = await fetchActivityFeed()
  return <ul>{feed.map(item => <li key={item.id}>{item.text}</li>)}</ul>
}

async function MetricsSidebar() {
  const metrics = await fetchMetrics()
  return <div>ยอดขาย: {metrics.sales}</div>
}

ผลลัพธ์คือ UserProfile ปรากฏใน 100ms, ActivityFeed ใน 800ms, MetricsSidebar ใน 2 วินาที — แต่ละส่วน stream เข้ามาทันทีที่พร้อม ไม่ต้องรอกัน สังเกตไหมครับว่าผู้ใช้ไม่ต้องรอ 2 วินาทีเพื่อเห็นโปรไฟล์ที่จริงๆ โหลดเสร็จภายใน 100ms

รูปแบบที่ 2: Streaming Promise ไปยัง Client Component ด้วย use()

React 19 มี API ใหม่ที่น่าสนใจมากชื่อ use() ซึ่งช่วยให้ Client Component รับ Promise จาก Server Component แล้ว resolve ได้:

// app/blog/page.tsx (Server Component)
import { Suspense } from 'react'
import PostList from '@/components/PostList'

export default function BlogPage() {
  // สำคัญ: ไม่ await — ส่ง promise ไปเลย
  const postsPromise = fetchPosts()
  
  return (
    <div>
      <h1>บทความล่าสุด</h1>
      <Suspense fallback={<PostListSkeleton />}>
        <PostList posts={postsPromise} />
      </Suspense>
    </div>
  )
}
// components/PostList.tsx (Client Component)
'use client'
import { use } from 'react'

interface Post {
  id: number
  title: string
}

export default function PostList({ posts }: { posts: Promise<Post[]> }) {
  const allPosts = use(posts) // React จะ suspend จนกว่า promise จะ resolve
  
  return (
    <ul>
      {allPosts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

ข้อดีตรงนี้คือคุณสามารถ เริ่ม fetch ข้อมูลบน server แต่ให้ Client Component เป็นคนจัดการแสดงผล เหมาะมากเวลาต้องการ interactivity หลังข้อมูลโหลดเสร็จ (เช่น sorting, filtering ฝั่ง client)

รูปแบบที่ 3: Sequential Streaming (ข้อมูลที่ขึ้นอยู่กับกัน)

บางครั้งข้อมูลส่วนที่สองต้องรอผลจากส่วนแรก ซึ่งจริงๆ แล้วเจอบ่อยมากในโปรเจกต์จริง เช่น ต้องรู้ artist ID ก่อนถึงจะดึง playlist ได้:

// app/artist/[username]/page.tsx
import { Suspense } from 'react'

export default async function ArtistPage({ params }) {
  const { username } = await params
  const artist = await getArtist(username) // ต้อง await เพราะ Playlists ต้องการ artist.id

  return (
    <div>
      <h1>{artist.name}</h1>  {/* แสดงทันที */}
      <p>{artist.bio}</p>
      
      <Suspense fallback={<PlaylistSkeleton />}>
        <Playlists artistID={artist.id} />  {/* stream เข้ามาทีหลัง */}
      </Suspense>
    </div>
  )
}

async function Playlists({ artistID }: { artistID: string }) {
  const playlists = await getPlaylists(artistID)
  return (
    <ul>
      {playlists.map(pl => <li key={pl.id}>{pl.name}</li>)}
    </ul>
  )
}

ชื่อศิลปินและ bio จะแสดงทันที ส่วน playlist จะ stream เข้ามาเมื่อ API ตอบกลับ แบบนี้ผู้ใช้ก็ไม่ต้องนั่งรอจอว่างครับ

รูปแบบที่ 4: Preload Pattern (เริ่ม fetch เร็วที่สุด)

เทคนิคขั้นสูงที่ผมชอบมาก — "จุดชนวน" fetch ให้เร็วที่สุดเท่าที่จะทำได้ ก่อนที่ component ที่ใช้ข้อมูลจะเริ่ม render:

// lib/data.ts
import { cache } from 'react'

// cache() ทำให้ถูกเรียกครั้งเดียวต่อ request แม้จะเรียกหลายจุด
export const getUser = cache(async () => {
  const res = await fetch('https://api.example.com/user')
  return res.json()
})

// preload function — เรียกเพื่อเริ่ม fetch เร็วๆ
export const preloadUser = () => {
  void getUser()
}
// app/profile/page.tsx
import { Suspense } from 'react'
import { preloadUser } from '@/lib/data'
import UserProfile from '@/components/UserProfile'

export default function ProfilePage() {
  preloadUser() // จุดชนวน fetch ทันที

  return (
    <div>
      <h1>โปรไฟล์ของฉัน</h1>
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile /> {/* component นี้เรียก getUser() อีกครั้ง แต่ได้ผลจาก cache */}
      </Suspense>
    </div>
  )
}

React.cache จะ deduplicate request ให้ — แม้เรียก getUser() สองครั้ง (ใน preload และใน component) แต่จะ fetch จริงแค่ครั้งเดียว เทคนิคนี้ช่วยลด latency ได้อีกนิดหน่อยโดยเฉพาะในหน้าที่มี component tree ลึกๆ

รูปแบบที่ 5: Suspense key สำหรับ Re-trigger Loading State

ปัญหาที่เจอบ่อยมาก: เมื่อเปลี่ยน URL parameter (เช่น จากหมวดหมู่ A เป็น B) Suspense ไม่ยอมแสดง loading state ใหม่ เพราะ React มองว่ายังเป็น component ตัวเดิม

แก้ง่ายๆ ด้วยการใส่ key prop:

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

export default async function ProductsPage({ searchParams }) {
  const { category } = await searchParams

  return (
    <div>
      <h1>สินค้า: {category}</h1>
      {/* key เปลี่ยน = Suspense re-mount = แสดง loading ใหม่ */}
      <Suspense key={category} fallback={<ProductListSkeleton />}>
        <ProductList category={category} />
      </Suspense>
    </div>
  )
}

พอ category เปลี่ยน React จะถือว่า Suspense เป็นตัวใหม่ทั้งหมด ทำให้แสดง skeleton ใหม่อย่างถูกต้อง เทคนิคเล็กๆ แต่สำคัญมากครับ

Parallel Data Fetching + Streaming: ดึงข้อมูลพร้อมกันยังไง

สิ่งที่ต้องรู้: React 19 เปลี่ยนพฤติกรรม Sibling Components

อันนี้สำคัญมากเลยนะครับ ถือเป็น breaking change จาก React 19 — sibling components ภายใน Suspense boundary เดียวกันจะ render แบบ sequential (ตามลำดับ) ไม่ใช่ parallel อีกต่อไป:

// ❌ ใน React 19: SlowA และ SlowB โหลดตามลำดับ (waterfall)
<Suspense fallback={<Spinner />}>
  <SlowComponentA />  {/* ต้องเสร็จก่อน */}
  <SlowComponentB />  {/* ถึงจะเริ่มได้ */}
</Suspense>

// ✅ แก้ไข: แยก Suspense boundary ให้โหลด parallel
<Suspense fallback={<SkeletonA />}>
  <SlowComponentA />  {/* โหลดอิสระ */}
</Suspense>
<Suspense fallback={<SkeletonB />}>
  <SlowComponentB />  {/* โหลดอิสระ */}
</Suspense>

ถ้าคุณเพิ่ง migrate มาจาก React 18 ตรงนี้ต้องระวังให้ดี ไม่งั้นอาจเจอ waterfall โดยไม่รู้ตัว

Promise.all สำหรับ Parallel Fetch ใน Component เดียว

ถ้าข้อมูลหลายตัวต้องใช้ใน component เดียวกัน ให้ใช้ Promise.all:

// app/artist/[username]/page.tsx
export default async function ArtistPage({ params }) {
  const { username } = await params

  // เริ่ม fetch พร้อมกัน ไม่ await ทีละตัว
  const artistData = getArtist(username)
  const albumsData = getAlbums(username)
  const eventsData = getUpcomingEvents(username)

  // รอทุกตัวพร้อมกัน
  const [artist, albums, events] = await Promise.all([
    artistData, albumsData, eventsData
  ])

  return (
    <div>
      <h1>{artist.name}</h1>
      <AlbumGrid albums={albums} />
      <EventList events={events} />
    </div>
  )
}

ข้อจำกัดที่ต้องรู้: ผู้ใช้จะไม่เห็นอะไรเลยจนกว่า ทุก promise จะ resolve ถ้าอยากให้แต่ละส่วนแสดงทีละชิ้น ก็กลับไปใช้ Independent Suspense Boundaries (รูปแบบที่ 1) แทนนะครับ

Streaming + Partial Prerendering (PPR): คู่หูที่ลงตัว

ถ้าคุณคุ้นเคยกับ Partial Prerendering อยู่แล้ว คุณจะรู้ว่า PPR ผสมข้อดีของ Static กับ Dynamic rendering เข้าด้วยกัน และ Streaming คือ กลไกที่ทำให้ PPR ทำงานได้จริงๆ

PPR ทำงานอย่างไรกับ Streaming

  1. ตอน build: Next.js render หน้าเว็บเหมือน SSG ปกติ แต่เมื่อเจอ <Suspense> ที่ครอบ dynamic component จะหยุด render ส่วนนั้น แล้วใส่ fallback placeholder แทน
  2. ตอน request: Static shell ถูกส่งจาก Edge CDN ทันที (TTFB ~40-90ms) จากนั้น dynamic component จะ render บน server และ stream เข้ามาแทนที่ placeholder
// app/product/[id]/page.tsx — ใช้งานร่วมกับ PPR
import { Suspense } from 'react'

// ส่วนนี้เป็น static — render ตอน build
async function ProductInfo({ id }: { id: string }) {
  'use cache'
  const product = await getProduct(id)
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span>฿{product.price}</span>
    </div>
  )
}

// ส่วนนี้เป็น dynamic — stream ทุก request
async function StockStatus({ id }: { id: string }) {
  const stock = await getRealtimeStock(id)
  return <span>คลัง: {stock.quantity} ชิ้น</span>
}

export default async function ProductPage({ params }) {
  const { id } = await params
  return (
    <div>
      <ProductInfo id={id} />
      <Suspense fallback={<span>กำลังตรวจสอบสต็อก...</span>}>
        <StockStatus id={id} />
      </Suspense>
    </div>
  )
}

ProductInfo ใช้ 'use cache' จึงเป็นส่วนของ static shell ส่วน StockStatus ไม่ได้ cache จึงเป็น dynamic hole ที่จะ stream เข้ามาทุก request ผมว่าตรงนี้คือจุดที่ Next.js 16 ออกแบบมาได้ดีมาก

ตัวเลขจริง: PPR + Streaming

Metricไม่มี PPR (SSR ล้วน)มี PPR + Streaming
TTFB350-550ms40-90ms
First Contentful Paint800-1200ms100-200ms
หน้าเว็บพร้อมใช้งานเต็มที่1500-2500ms500-1500ms

ตัวเลขพวกนี้ต่างกันชัดเจนเลยนะครับ โดยเฉพาะ TTFB ที่เร็วขึ้นหลายเท่า

Streaming + use cache: ผสมกันอย่างไรให้ถูกต้อง

การจับคู่ Streaming กับ use cache ใน Next.js 16 เป็นกุญแจสำคัญในการสร้างแอปที่ทั้งเร็วและแสดงข้อมูลสดได้

หลักการง่ายๆ

  • ข้อมูลที่เปลี่ยนไม่บ่อย → ใช้ 'use cache' เพื่อให้เป็นส่วนของ static shell
  • ข้อมูลที่ต้องสดใหม่ทุก request → ใส่ใน <Suspense> โดยไม่ใช้ cache เพื่อ stream ทุกครั้ง
  • ข้อมูล personalized → ใช้ 'use cache: private' ร่วมกับ Suspense
// ตัวอย่างผสม cache + streaming บนหน้าเดียวกัน
import { Suspense } from 'react'

// ✅ Cached — ข้อมูลร้านค้าไม่เปลี่ยนบ่อย
async function ShopInfo() {
  'use cache'
  const shop = await getShopDetails()
  return <header>{shop.name} — {shop.description}</header>
}

// ✅ Streamed — ข้อมูลสดใหม่ทุก request
async function LiveInventory() {
  const inventory = await getLiveInventory()
  return (
    <ul>
      {inventory.map(item => (
        <li key={item.id}>{item.name}: {item.stock} ชิ้น</li>
      ))}
    </ul>
  )
}

export default function ShopPage() {
  return (
    <div>
      <ShopInfo />  {/* จาก cache — แสดงทันที */}
      <Suspense fallback={<InventorySkeleton />}>
        <LiveInventory />  {/* stream เข้ามาเมื่อข้อมูลพร้อม */}
      </Suspense>
    </div>
  )
}

ข้อจำกัดที่ต้องจำ

Component ที่ใช้ 'use cache' ไม่สามารถ เข้าถึง cookies(), headers() หรือ searchParams ได้โดยตรง ต้องอ่านค่าข้างนอกแล้วส่งเป็น argument เข้าไป:

// ❌ ผิด — cookies() ใน use cache
async function UserGreeting() {
  'use cache'
  const cookieStore = await cookies()  // Error!
  const name = cookieStore.get('name')
  return <p>สวัสดี {name}</p>
}

// ✅ ถูก — ส่ง name เป็น argument
async function UserGreeting({ name }: { name: string }) {
  'use cache'
  // ใช้ name ได้เลย
  return <p>สวัสดี {name}</p>
}

ตรงนี้อาจดูเหมือนข้อจำกัดแปลกๆ แต่จริงๆ แล้วมันเป็น design ที่ดี เพราะถ้า cache ได้ผลลัพธ์ที่ขึ้นอยู่กับ cookies ก็ไม่ใช่ cache ที่ปลอดภัยแล้วครับ

ประสิทธิภาพจริง: ตัวเลขและ Benchmark

พอพูดเรื่อง performance หลายคนอาจสงสัยว่า "เร็วขึ้นจริงเหรอ?" มาดูตัวเลขกันเลย

Next.js 16 vs 15: Benchmark เปรียบเทียบ

MetricNext.js 15Next.js 16ปรับปรุง
SSR Throughput322 req/s701 req/s2x+
Average Latencybaselineลดลง 6 เท่า6x ดีขึ้น
RSC Deserializationbaselineเร็วขึ้น ~75%4x ดีขึ้น
HTML Renderingbaselineเร็วขึ้น 25-60%ดีขึ้นมาก
Dev Startupbaselineเร็วขึ้น ~400%5x ดีขึ้น

SSR Throughput จาก 322 เป็น 701 req/s นี่คือมากกว่าสองเท่าเลย พูดตรงๆ ว่าน่าประทับใจมากครับ

Client-side JavaScript ลดลงอย่างไร

เมื่อใช้ React Server Components ร่วมกับ Streaming อย่างถูกต้อง:

  • Client JS ลดลงได้ถึง 70% — เพราะ Server Components ไม่ส่ง JavaScript ไปยัง client เลย
  • เป้าหมาย First Load JS: ต่ำกว่า 100KB compressed ต่อ route
  • เป้าหมาย TTFB: ต่ำกว่า 200ms ด้วย Streaming

Turbopack: Dev Experience ที่เร็วขึ้นมาก

Next.js 16 ใช้ Turbopack เป็นค่าเริ่มต้น ซึ่งส่งผลต่อ DX อย่างเห็นได้ชัด:

เว็บไซต์Cold StartWith FS Cacheเร็วขึ้น
react.dev3.7s380ms~10x
nextjs.org3.5s700ms~5x
Large Vercel app15s1.1s~14x

จาก 15 วินาทีเหลือ 1.1 วินาที สำหรับโปรเจกต์ขนาดใหญ่... ผมว่าแค่นี้ก็คุ้มค่าที่จะ upgrade แล้วครับ

Best Practices และ Anti-Patterns

สิ่งที่ควรทำ

  1. แยก Suspense boundary ให้แต่ละส่วนข้อมูลที่เป็นอิสระจากกัน — นี่คือกฎข้อแรกที่สำคัญที่สุด
  2. ย้าย data fetching ลงไปอยู่ใน component ที่ใช้ข้อมูลนั้น แล้วครอบด้วย Suspense
  3. ทำให้ skeleton มีขนาดตรงกับ content จริง เพื่อป้องกัน CLS (Cumulative Layout Shift)
  4. ใช้ 'use client' ให้น้อยที่สุด — ดันลงไปที่ leaf component ที่ต้องการ interactivity จริงๆ เท่านั้น
  5. วัดผล TTFB, LCP, CLS ด้วย Real User Monitoring ก่อนและหลังเพิ่ม Streaming
  6. ปิด reverse proxy buffering เมื่อ self-host (เช่น proxy_buffering off ใน Nginx) ไม่งั้น Streaming จะไม่ทำงานตามที่คาดหวัง

สิ่งที่ไม่ควรทำ (Anti-Patterns)

  1. อย่าใส่ Suspense ข้างใน async component — Suspense ต้องอยู่ เหนือ component ที่ fetch ข้อมูลใน component tree
// ❌ ผิด — Suspense อยู่ข้างใน async component
async function DataComponent() {
  return (
    <Suspense fallback={<Spinner />}> {/* ไม่ทำงาน! */}
      {await fetchData()}
    </Suspense>
  )
}

// ✅ ถูก — Suspense ครอบจากข้างนอก
function ParentComponent() {
  return (
    <Suspense fallback={<Spinner />}>
      <DataComponent />
    </Suspense>
  )
}
  1. อย่าใช้ Suspense boundary เดียวครอบหลาย component อิสระ — ใน React 19 จะกลายเป็น waterfall ทันที
  2. อย่าใช้ useEffect fetch ข้อมูลใน Client Component เมื่อ Server Component ทำได้ มันทั้งช้ากว่าและเพิ่ม bundle size โดยไม่จำเป็น
  3. อย่าลืมแยก dynamic fetch ออกจาก static content — ไม่งั้นทั้งหน้าจะกลายเป็น dynamic ทั้งหมด
  4. อย่าใช้ 'use client' มากเกินไป — ทุก component ที่มาร์ก use client (รวมถึง children ทั้งหมด) จะถูกส่งเป็น JavaScript ไปยัง client

เรื่อง SEO: Streaming ปลอดภัยสำหรับ SEO

คำถามที่หลายคนกังวล — Streaming มีผลกระทบต่อ SEO ไหม? คำตอบสั้นๆ คือ ไม่

  • Next.js จะ resolve generateMetadata ก่อน เริ่ม stream UI ทำให้ metadata อยู่ใน <head> ของ HTML แรกเสมอ
  • Googlebot รองรับ streaming HTML ได้อย่างสมบูรณ์
  • สำหรับ bot ที่อ่านได้แค่ static HTML (เช่น Twitterbot) Next.js จะตรวจจับ user agent และส่ง HTML เต็มให้

ข้อควรระวังเรื่อง Status Code

เมื่อใช้ Streaming เซิร์ฟเวอร์จะส่ง status code 200 ก่อนส่ง content ใดๆ ซึ่งหมายความว่า ไม่สามารถเปลี่ยน status code ได้หลังจาก streaming เริ่มแล้ว สำหรับหน้า 404 Next.js จะใส่ <meta name="robots" content="noindex"> ใน HTML ที่ stream ออกมาแทน ตรงนี้ต้องระวังนิดนึงนะครับ

คำถามที่พบบ่อย (FAQ)

Streaming ใน Next.js 16 ต่างจาก SSR ธรรมดาอย่างไร?

SSR ธรรมดาต้อง render HTML ทั้งหน้าให้เสร็จก่อนจึงส่งกลับไปยัง client ได้ แต่ Streaming จะส่ง HTML shell ทันทีพร้อม placeholder สำหรับส่วนที่ยังโหลดไม่เสร็จ จากนั้นค่อยๆ stream เข้ามาแทนที่ ผลคือ TTFB เร็วขึ้นมากและผู้ใช้เห็นเนื้อหาเร็วกว่าเยอะ

loading.js กับ Suspense ต่างกันอย่างไร ควรเลือกใช้อะไร?

loading.js ทำงานระดับ route segment ทั้งหมดโดยอัตโนมัติ เหมาะสำหรับ loading state ของทั้งหน้า ส่วน <Suspense> ให้ความยืดหยุ่นมากกว่าในการกำหนดว่าส่วนไหนจะมี loading state แยกกัน ถ้าหน้ามีหลายส่วนข้อมูล ผมแนะนำให้ใช้ <Suspense> หลายตัวเพื่อให้แต่ละส่วน stream อิสระจากกัน

React 19 Suspense เปลี่ยนอะไรจาก React 18 บ้าง?

การเปลี่ยนแปลงสำคัญที่สุดคือ sibling components ภายใน Suspense boundary เดียวกันจะ render แบบ sequential แทนที่จะเป็น parallel เหมือน React 18 วิธีแก้คือแยก Suspense boundary ให้แต่ละ component นอกจากนี้ยังมี use() API ใหม่ที่ช่วยให้ Client Component รับ Promise จาก Server Component ได้สะดวกขึ้นด้วย

Streaming มีผลเสียต่อ SEO หรือไม่?

ไม่มีผลเสียครับ Next.js จะ resolve metadata ก่อนเริ่ม streaming เสมอ ทำให้ search engine ได้รับ metadata ครบถ้วน Googlebot รองรับ streaming HTML ได้สมบูรณ์ และสำหรับ bot อื่นๆ ที่ไม่รองรับ Next.js จะตรวจจับ user agent แล้วส่ง HTML แบบเต็มให้

Streaming ทำงานร่วมกับ PPR และ use cache ได้อย่างไร?

PPR ใช้ Streaming เป็นกลไกหลัก — static shell ถูกส่งจาก CDN ทันที (~40-90ms TTFB) จากนั้น dynamic content ใน Suspense boundary จะ stream เข้ามา ส่วน use cache ทำให้ component กลายเป็นส่วนของ static shell ที่ไม่ต้อง stream ทุก request หลักง่ายๆ คือ ใช้ use cache กับข้อมูลที่ไม่เปลี่ยนบ่อย และใช้ Suspense + Streaming กับข้อมูลที่ต้องสดใหม่ทุกครั้ง

เกี่ยวกับผู้เขียน Editorial Team

Our team of expert writers and editors.