ข้ามไปยังเนื้อหา

Streaming และ Suspense

streaming ให้ server ส่งส่วนที่พร้อมแล้วของหน้า — คือ static shell — ออกไปทันที แล้วค่อยดันส่วนที่ช้ากว่าตามมาทาง connection เดียวกันเมื่อ render เสร็จ

ถ้าไม่มี streaming server จะกั๊ก response ทั้งก้อนไว้จนกว่าทุก await จะ resolve ดังนั้นชิ้นที่ data ช้าที่สุดจะเป็นตัวกำหนด time to first byte streaming ตัดหน้าออกเป็น chunk — shell (layout, heading, อะไรก็ตามที่ไม่รอ data) ออกจาก server ก่อน ส่วนที่ช้าตามมาเมื่อ resolve

browser paint shell ทันที ผู้ใช้จึงเห็นหน้าจริงในขณะที่ data ยังโหลดอยู่เบื้องหลัง

วาง loading.tsx ไว้ข้าง ๆ page.tsx แล้ว Next.js จะห่อทั้ง segment นั้นด้วย Suspense boundary ให้เอง ระหว่างที่ page รอ data อยู่ loading UI จะโชว์ทันที

app/feed/loading.tsx
export default function Loading() {
return <p>Loading your feed…</p>;
}

คุณไม่ต้องเขียน <Suspense> เองตรงนี้ — file convention คือ boundary เอง segment จะ stream เข้ามาเมื่อ data พร้อม

เมื่อมีแค่ region เดียวของหน้าที่ช้า ให้ห่อ region นั้นด้วย <Suspense> ส่วนที่เหลือของหน้าจะ render ทันที และเฉพาะ component ที่ช้าเท่านั้นที่ stream เข้ามา

app/feed/page.tsx
import { Suspense } from 'react';
async function SlowComments() {
const comments: { id: number; text: string }[] = await fetch('https://api.example.com/comments', {
cache: 'no-store',
}).then((r) => r.json());
return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}
export default function FeedPage() {
return (
<section>
<h1>Your feed</h1>
<p>This heading and text ship in the shell, immediately.</p>
<Suspense fallback={<p>Loading comments…</p>}>
<SlowComments />
</Suspense>
</section>
);
}

heading paint ทันที ส่วน SlowComments stream เข้ามาแล้วแทนที่ fallback เมื่อ fetch resolve

loading.tsx กับ <Suspense> แบบ manual แก้ปัญหาเดียวกันแต่คนละระดับความละเอียด — loading.tsx ครอบทั้ง segment (ทุก dynamic API และทุก fetch ในหน้านั้นรอหลัง fallback เดียวกันหมด) ส่วน boundary แบบ manual ให้คุณตัดเฉพาะ region ที่ช้าออกมา แล้วเก็บส่วนที่เหลือของหน้า — ส่วนที่เร็วอยู่แล้ว — ไว้นอก fallback ทั้งหมด ใช้ loading.tsx สำหรับการโหลดครั้งแรกของทั้ง route และใช้ <Suspense> แบบ manual เมื่อมีแค่ component เดียวในหน้าที่เร็วอยู่แล้วที่ช้า

การห่อ ทั้ง หน้าด้วย <Suspense> เดียว ยังทำให้ server flush response header ชุดแรกได้เร็วอยู่ดี แต่ถ้าไม่มีอะไรที่มีความหมายอยู่นอก boundary นั้นเลย ก็ไม่มี shell จริง ๆ ให้โชว์ — ผู้เข้าชมจ้อง fallback นานเท่ากับเวลารวมที่ใช้ถ้าไม่มี Suspense เลย พิสูจน์ด้วย server จริงกับ script วัดเวลา chunk (Accept-Encoding: identity ปิด compression ไม่ให้ chunk ถูก buffer โดย layer นั้น ตรงกับเทคนิคที่ docs ของ Next.js ใช้ยืนยัน streaming) —

app/stream-none/page.tsx
export const dynamic = 'force-dynamic';
async function slow() {
await new Promise((r) => setTimeout(r, 1500));
return 'slow data';
}
export default async function Page() {
const data = await slow(); // no Suspense at all — the whole response blocks
return (
<main>
<h1>Shell heading</h1>
<p>{data}</p>
</main>
);
}
app/stream-wrap-all/page.tsx
export const dynamic = 'force-dynamic';
import { Suspense } from 'react';
async function Slow() {
await new Promise((r) => setTimeout(r, 1500));
return <p>slow data</p>;
}
export default function Page() {
return (
<Suspense fallback={<p>Loading…</p>}>
<main>
<h1>Shell heading</h1>
<Slow />
</main>
</Suspense>
);
}
app/stream-granular/page.tsx
export const dynamic = 'force-dynamic';
import { Suspense } from 'react';
async function Slow() {
await new Promise((r) => setTimeout(r, 1500));
return <p>slow data</p>;
}
export default function Page() {
return (
<main>
<h1>Shell heading</h1>
<p>This ships immediately, in the shell.</p>
<Suspense fallback={<p>Loading…</p>}>
<Slow />
</Suspense>
</main>
);
}

รันทั้งสามอันกับ next start แล้วอ่าน raw HTTP chunk ตามที่มันมาถึงจริง (timestamp เทียบกับตอนที่ request ถูกส่งไป) —

stream-none headers +1536ms chunk 0 +1540ms (chunk เดียว ไม่มีอะไรมาก่อนหน้า)
stream-wrap-all headers +32ms chunk 1 +1530ms chunk 2 +1531ms (มีแค่ fallback แล้วทุกอย่างมาพร้อมกันทีเดียว)
stream-granular headers +28ms chunk 1 +1528ms chunk 2 +1529ms (shell content จริง แล้วตามด้วยส่วนที่ช้า)

stream-wrap-all ได้ time-to-first-byte เร็วในทางเทคนิค — fallback เองก็ stream ออกมาที่ 32ms — แต่ผู้เข้าชมไม่เห็นอะไรนอกจาก spinner จนถึง 1531ms เวลารวมเท่ากับ stream-none ที่ blocking เป็น response เดียวที่ 1540ms stream-granular ได้ header เร็วเท่ากัน และ โชว์ content จริงที่มีประโยชน์ (heading กับ text แนะนำ) ตลอดช่วงเวลารอนั้นด้วย ข้อดีของ <Suspense> แบบ manual ไม่ใช่เวลารวมที่เร็วขึ้น — แต่คือ content จริงที่พร้อมโดยไม่ต้องเสียเวลารออะไรเลย แทนที่จะเป็น boundary ที่กว้างจนมี fallback เป็นสิ่งเดียวที่โชว์ได้

layer ไหนก็ตามระหว่าง server กับ browser ที่ buffer ทั้ง response ก่อนส่งต่อ ก็ทำลาย streaming ได้เลย ถึงแม้ server จะสร้างมันแบบ progressive จริง ๆ ก็ตาม —

  • reverse proxy — Nginx และ proxy คล้าย ๆ กัน buffer response เป็น default ทางแก้จาก docs ของ Next.js คือ response header ไม่ใช่ nginx directive — ตั้ง X-Accel-Buffering: no บน response
  • CDN — บางตัว buffer ทั้ง response ก่อนส่งต่อ เช็ก streaming support ของ provider ตัวเอง
  • serverless platform — ไม่ใช่ทุกตัวรองรับ streaming เป็น default AWS Lambda ต้องเปิด response-streaming mode เองแบบชัดเจน
  • compression — gzip/Brotli buffer chunk ข้างในเพื่อ compress ให้มีประสิทธิภาพ เพิ่ม latency ก่อน chunk แรกที่เห็นได้
next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/:path*{/}?',
headers: [{ key: 'X-Accel-Buffering', value: 'no' }],
},
];
},
};
export default nextConfig;

streaming แยก “first paint” ออกจาก “data ที่ช้าที่สุด” มีสองข้อดีที่ตามมา —

  • TTFB ดีขึ้น — shell ออกจาก server โดยไม่รอ data request ใด ๆ byte แรกจึงมาถึงเร็ว
  • perceived performance ดีขึ้น — ผู้ใช้อ่าน layout จริงได้ระหว่างที่ region ช้ากำลังเติมเข้ามา แทนที่จะจ้องหน้าจอว่างจนกว่าทุกอย่างจะพร้อม — ตราบใดที่มี content จริงอยู่ใน shell ไม่ใช่แค่ Suspense fallback ที่ห่อทุกอย่างไว้
flowchart LR
  A[Request] --> B[Server sends static shell now]
  B --> C[Browser paints shell, fast TTFB]
  D[Slow async component resolves] --> E[Server streams the chunk]
  E --> F[Suspense fallback swaps to real content]
ส่ง shell ก่อน แล้วค่อย stream ส่วนที่ช้า

นึกภาพ response เป็นรถขนของที่ส่งพัสดุทีละชิ้นได้แทนที่จะรอจนพัสดุทุกชิ้นบนเส้นทางถูกโหลดครบ loading.tsx คือ “กักรถทั้งคันไว้ที่ depot จนกว่า route นี้จะพร้อม” — หยาบ แต่อัตโนมัติ <Suspense> แบบ manual คือ “โหลดรถด้วยทุกอย่างที่พร้อมตอนนี้ แล้วส่งรถอีกคันไปรับพัสดุชิ้นเดียวที่ยังแพ็คไม่เสร็จ” — คนขับ (browser) ออกเดินทางทันทีพร้อมของจริง การห่อทั้งหน้าด้วย <Suspense> เดียว ก็คือ trick แบบ depot อันเดิม แค่เปลี่ยนชื่อเรียก — รถยังไม่ออกไปพร้อมอะไรที่มีประโยชน์จนกว่าพัสดุชิ้นสุดท้ายจะมาถึง

สิ่งที่ได้ประโยชน์ต้นทุน
loading.tsxSuspense boundary แบบไม่ต้องเขียน code เองสำหรับทั้ง segmentหยาบ — fallback ของทั้ง segment โชว์แม้จะมีแค่ fetch เดียวที่ช้า
<Suspense> แบบ manual รอบ component เดียวส่วนที่เหลือของหน้า render ทันทีพร้อม content จริงต้องระบุให้ได้ว่า component ไหนช้าจริง ๆ
<Suspense> ห่อทั้งหน้าไม่ได้อะไรเพิ่มเลยเทียบกับไม่ stream ถ้าไม่มีอะไรอยู่นอกมันรอเวลารวมเท่าเดิมกับแบบ blocking แต่ดูเหมือนน่าจะช่วยได้
X-Accel-Buffering: noคืน behavior streaming จริงหลัง reverse proxy สไตล์ Nginxต้องตั้งเองชัดเจน — default เงียบ ๆ ลด streaming ให้เหลือ chunk ก้อนเดียว
  • ห่อหน้ามากขึ้นด้วย <Suspense> ช่วยได้เสมอ boundary มีประโยชน์ก็ต่อเมื่อมี content จริงที่พร้อมแล้วอยู่ นอก มัน การห่อทุกอย่างแค่ทำให้ content เดิมช้าลงเบื้องหลัง fallback แทนที่จะโชว์เร็วขึ้น
  • loading.tsx กับ <Suspense> แบบ manual ใช้แทนกันได้ loading.tsx ครอบทั้ง segment ทีเดียว ส่วน boundary แบบ manual ให้คุณเก็บบางส่วนของหน้าไว้นอก fallback ใด ๆ เลย ใช้แบบละเอียดเมื่อมีแค่บางส่วนของหน้าที่ช้า
  • ถ้า server stream, browser ก็เห็นแบบ stream เสมอ reverse proxy, CDN, compression layer หรือแม้แต่ HTTP client ที่ buffer เอง ก็เปลี่ยน response ที่ progressive จริง ๆ จาก server ให้กลายเป็น blob เดียวที่ล่าช้าได้ตอนที่มันไปถึง code ที่อ่านมัน
  • streaming ทำให้ data ช้าเร็วขึ้น ไม่จริง — มันไม่ได้ทำให้ request ที่ช้าสั้นลง มันแค่เปลี่ยนสิ่งที่ผู้เข้าชมเห็น ระหว่างรอ จากหน้าจอว่างเปล่าให้กลายเป็นหน้าจริงที่ยังไม่ครบสมบูรณ์

เมื่อ snippet ที่ generate มาแตะ streaming หรือ Suspense ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน —

  • <Suspense> boundary ถูกห่อรอบทุกอย่างในหน้าจริง ๆ โดยไม่มีอะไรมีความหมายอยู่นอกมันเลยหรือเปล่า? แบบนั้นได้ TTFB เร็วสำหรับ fallback ไม่ใช่สำหรับ content จริง — เช็กว่ามีอะไรที่เร็วอยู่แล้วซึ่งดึงออกมานอก boundary ได้หรือเปล่า
  • await ที่ช้าอยู่ข้างใน component ที่ถูกห่อด้วย <Suspense> จริง ๆ หรือถูก await ไว้อีกชั้นบนสุด ใน parent ก่อนที่ child ที่ห่อ <Suspense> จะถูก render ด้วยซ้ำ? await ที่อยู่เหนือ boundary จะ block ทั้ง tree ไม่ว่า <Suspense> ข้างล่างจะวางไว้ตรงไหน
  • มี loading.tsx สำหรับ segment ที่จริง ๆ มีแค่ component เดียวที่ช้า ทั้งที่ <Suspense> แบบ manual รอบเฉพาะ component นั้นจะให้ส่วนที่เหลือของหน้า render ทันทีได้หรือเปล่า?
  • deployment guidance (Dockerfile, nginx config, reverse-proxy setup) ลืมปิด response buffering หรือเปล่า? streaming ทำงานถูกต้องสมบูรณ์บน next start ได้ แล้วยังมาถึงเป็น blob เดียวใน production หลัง proxy ที่ไม่ได้ตั้งค่าไว้

🐛 หา bug ในโค้ดนี้

app/feed/page.tsx
import { Suspense } from 'react'
async function getFeed() {
return fetch('https://api.example.com/feed', { cache: 'no-store' }).then((r) => r.json())
}
async function Comments() {
const comments = await fetch('https://api.example.com/comments', { cache: 'no-store' }).then((r) => r.json())
return <ul>{comments.map((c: { id: number; text: string }) => <li key={c.id}>{c.text}</li>)}</ul>
}
export default async function FeedPage() {
const feed = await getFeed()
return (
<section>
<h1>Your feed</h1>
<p>{feed.headline}</p>
<Suspense fallback={<p>Loading comments…</p>}>
<Comments />
</Suspense>
</section>
)
}
เฉลย

<Suspense> รอบ Comments ดูถูกต้องถ้ามองแยก ๆ แต่ FeedPage เองเป็น async function ที่ await getFeed() ก่อน ที่จะ return JSX ใด ๆ เลย — รวมถึง <Suspense> boundary ด้วย ไม่มีอะไร stream ได้จนกว่า await ตัวนอกนั้นจะ resolve เพราะ React ยังไม่ได้เริ่ม render tree ที่มี boundary อยู่ข้างในด้วยซ้ำ ทางแก้คือ render heading กับ shell แบบ synchronous แล้วย้าย getFeed() ไปไว้ใน child component ของตัวเอง ห่อด้วย <Suspense> ของตัวเอง — ทางแก้เดียวกับที่ใช้กับ boundary ที่ห่อทั้งหน้า แค่ย้ายมาใช้อีกชั้นสูงขึ้นไป

อะไรถูกส่งไปที่ browser ก่อนเมื่อหน้า stream?
ไฟล์ loading.tsx สร้างอะไรให้ segment ของตัวเอง?
ควรใช้ <Suspense> แบบ manual เมื่อไหร่?
ทำไม streaming ช่วยเรื่อง time to first byte?
วัดกับ server จริง หน้านี้กับหน้าเทียบเท่าที่ไม่มี Suspense เลย ใช้เวลารวมพอ ๆ กันกว่าจะโชว์ content จริง ทำไม?
export default function Page() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <main>
        <h1>Shell heading</h1>
        <Slow />
      </main>
    </Suspense>
  )
}
Suspense boundary รอบ Comments ทำให้ heading stream ออกมาก่อน getFeed() resolve ได้ไหม?
export default async function Page() {
  const feed = await getFeed() // slow fetch
  return (
    <section>
      <h1>{feed.headline}</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <Comments />
      </Suspense>
    </section>
  )
}
หน้า stream ถูกต้องจาก next start แต่ผู้เข้าชมทุกคนเห็น response เดียวที่ล่าช้าใน production หลัง Nginx reverse proxy ทางแก้ที่ docs ระบุไว้คืออะไร?