Streaming และ Suspense
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”streaming ให้ server ส่งส่วนที่พร้อมแล้วของหน้า — คือ static shell — ออกไปทันที แล้วค่อยดันส่วนที่ช้ากว่าตามมาทาง connection เดียวกันเมื่อ render เสร็จ
static shell stream ออกมาก่อน
หัวข้อที่มีชื่อว่า “static shell stream ออกมาก่อน”ถ้าไม่มี streaming server จะกั๊ก response ทั้งก้อนไว้จนกว่าทุก await จะ resolve ดังนั้นชิ้นที่ data ช้าที่สุดจะเป็นตัวกำหนด time to first byte streaming ตัดหน้าออกเป็น chunk — shell (layout, heading, อะไรก็ตามที่ไม่รอ data) ออกจาก server ก่อน ส่วนที่ช้าตามมาเมื่อ resolve
browser paint shell ทันที ผู้ใช้จึงเห็นหน้าจริงในขณะที่ data ยังโหลดอยู่เบื้องหลัง
loading.tsx คือ Suspense boundary อัตโนมัติ
หัวข้อที่มีชื่อว่า “loading.tsx คือ Suspense boundary อัตโนมัติ”วาง loading.tsx ไว้ข้าง ๆ page.tsx แล้ว Next.js จะห่อทั้ง segment นั้นด้วย Suspense boundary ให้เอง ระหว่างที่ page รอ data อยู่ loading UI จะโชว์ทันที
export default function Loading() { return <p>Loading your feed…</p>;}คุณไม่ต้องเขียน <Suspense> เองตรงนี้ — file convention คือ boundary เอง segment จะ stream เข้ามาเมื่อ data พร้อม
manual Suspense stream เฉพาะส่วนที่ช้า
หัวข้อที่มีชื่อว่า “manual Suspense stream เฉพาะส่วนที่ช้า”เมื่อมีแค่ region เดียวของหน้าที่ช้า ให้ห่อ region นั้นด้วย <Suspense> ส่วนที่เหลือของหน้าจะ render ทันที และเฉพาะ component ที่ช้าเท่านั้นที่ stream เข้ามา
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 ที่ห่อทั้งหน้าไม่ได้อะไรเพิ่ม
หัวข้อที่มีชื่อว่า “Suspense ที่ห่อทั้งหน้าไม่ได้อะไรเพิ่ม”การห่อ ทั้ง หน้าด้วย <Suspense> เดียว ยังทำให้ server flush response header ชุดแรกได้เร็วอยู่ดี แต่ถ้าไม่มีอะไรที่มีความหมายอยู่นอก boundary นั้นเลย ก็ไม่มี shell จริง ๆ ให้โชว์ — ผู้เข้าชมจ้อง fallback นานเท่ากับเวลารวมที่ใช้ถ้าไม่มี Suspense เลย พิสูจน์ด้วย server จริงกับ script วัดเวลา chunk (Accept-Encoding: identity ปิด compression ไม่ให้ chunk ถูก buffer โดย layer นั้น ตรงกับเทคนิคที่ docs ของ Next.js ใช้ยืนยัน streaming) —
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> );}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> );}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 เป็นสิ่งเดียวที่โชว์ได้
อะไรที่พัง streaming ใน production
หัวข้อที่มีชื่อว่า “อะไรที่พัง streaming ใน production”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 แรกที่เห็นได้
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { async headers() { return [ { source: '/:path*{/}?', headers: [{ key: 'X-Accel-Buffering', value: 'no' }], }, ]; },};
export default nextConfig;ทำไม streaming ช่วยเรื่อง perceived performance
หัวข้อที่มีชื่อว่า “ทำไม streaming ช่วยเรื่อง perceived performance”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]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”นึกภาพ response เป็นรถขนของที่ส่งพัสดุทีละชิ้นได้แทนที่จะรอจนพัสดุทุกชิ้นบนเส้นทางถูกโหลดครบ loading.tsx คือ “กักรถทั้งคันไว้ที่ depot จนกว่า route นี้จะพร้อม” — หยาบ แต่อัตโนมัติ <Suspense> แบบ manual คือ “โหลดรถด้วยทุกอย่างที่พร้อมตอนนี้ แล้วส่งรถอีกคันไปรับพัสดุชิ้นเดียวที่ยังแพ็คไม่เสร็จ” — คนขับ (browser) ออกเดินทางทันทีพร้อมของจริง การห่อทั้งหน้าด้วย <Suspense> เดียว ก็คือ trick แบบ depot อันเดิม แค่เปลี่ยนชื่อเรียก — รถยังไม่ออกไปพร้อมอะไรที่มีประโยชน์จนกว่าพัสดุชิ้นสุดท้ายจะมาถึง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
loading.tsx | Suspense 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 ที่ช้าสั้นลง มันแค่เปลี่ยนสิ่งที่ผู้เข้าชมเห็น ระหว่างรอ จากหน้าจอว่างเปล่าให้กลายเป็นหน้าจริงที่ยังไม่ครบสมบูรณ์
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ 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 ในโค้ดนี้
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 ที่ห่อทั้งหน้า แค่ย้ายมาใช้อีกชั้นสูงขึ้นไป