Trang dashboard của mình hồi trước mất hơn 3 giây mới hiện được chữ đầu tiên. Mở log ra coi thì mỗi query chỉ 200–300ms, cộng lại chưa tới 1.4 giây. Vậy 1.8 giây còn lại đi đâu? Hoá ra cả trang đang xếp hàng chờ nhau: query sau chỉ bắt đầu khi query trước trả về, và trình duyệt thì chưa nhận được byte HTML nào cho tới khi hàm async cuối cùng chạy xong. Sửa đúng chỗ đó, thời gian hiện chữ đầu tiên tụt từ 3.2s xuống 0.5s mà không thêm cache, không thêm server.
Vì sao await liên tiếp tạo thác nước
Trong App Router, một page là Server Component nên bạn await thẳng trong thân hàm. Tiện, nhưng cũng dễ viết thành chuỗi phụ thuộc:
// app/dashboard/page.tsx
export default async function Dashboard() {
const user = await getUser() // 250ms
const orders = await getOrders(user.id) // 400ms
const stats = await getStats(user.id) // 700ms
const notes = await getNotes(user.id) // 300ms
return (
<main>
<Header user={user} />
<Orders items={orders} />
<Stats data={stats} />
<Notes items={notes} />
</main>
)
}
Bốn dòng await là bốn lần chờ nối đuôi: 1.65 giây cộng dồn. Đây là waterfall kinh điển — không phải database chậm, mà là code tự xếp hàng.
Promise.all chỉ chữa được nửa bệnh
Sửa nhanh nhất là cho những query độc lập chạy song song:
const user = await getUser()
const [orders, stats, notes] = await Promise.all([
getOrders(user.id),
getStats(user.id),
getNotes(user.id),
])
Thời gian server tụt xuống còn khoảng 950ms (250ms + query chậm nhất). Nhưng người dùng vẫn nhìn màn hình trắng suốt 950ms đó, vì React phải render xong cả cây rồi mới gửi HTML xuống. Chỉ cần một mảnh chậm là cả trang chờ theo. Mảnh chậm đó thường là thứ ít quan trọng nhất — bảng thống kê, biểu đồ, một block "gợi ý cho bạn".
Suspense boundary: chia trang thành nhiều gói gửi trước
Đây là chỗ streaming thật sự vào cuộc. Bọc phần chậm vào một Suspense boundary, Next.js sẽ gửi phần đã render xong trước, rồi stream phần còn lại xuống sau khi nó sẵn sàng:
import { Suspense } from 'react'
export default async function Dashboard() {
const user = await getUser()
return (
<main>
<Header user={user} />
<Suspense fallback={<OrdersSkeleton rows={5} />}>
<Orders userId={user.id} />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<Stats userId={user.id} />
</Suspense>
</main>
)
}
Và mỗi phần tự lo dữ liệu của nó:
async function Stats({ userId }: { userId: string }) {
const stats = await getStats(userId) // 700ms — chỉ mình nó chờ
return <StatsChart data={stats} />
}
Bây giờ thứ tự thời gian khác hẳn: header + khung trang hiện ở ~400ms, bảng đơn hàng ở ~700ms, biểu đồ thống kê ở ~1.1s. Người dùng bắt đầu đọc và bấm được trong khi phần chậm còn đang chạy. Cùng một lượng query, cùng một server — chỉ khác cách chia việc.
Vài chỗ dễ sai khi đặt boundary
- Đặt quá cao. Bọc cả page trong một
Suspensethì fallback hiện ra rồi cả trang thay một lần — coi như không stream. Boundary nên nằm sát đúng component chậm. - Quên
loading.tsx. Fileapp/dashboard/loading.tsxđược Next.js tự bọc thành một Suspense boundary quanh page. Có nó, điều hướng giữa các tab không còn đứng im. - Nhồi dữ liệu chậm vào layout. Layout chạy ở mọi route con, chậm một chỗ là chậm hết. Dữ liệu riêng của từng trang thì để trong trang.
- Client Component cũng stream được. Nếu phần chậm nằm trong Client Component, truyền một Promise từ server xuống rồi đọc bằng
use()trongSuspense. ĐừnguseEffect+useStateđể fetch — bạn vừa mất streaming, vừa thêm một vòng round-trip.
'use client'
import { use, Suspense } from 'react'
export function StatsBox({ statsPromise }: { statsPromise: Promise<Stats> }) {
return (
<Suspense fallback={<StatsSkeleton />}>
<StatsChart data={use(statsPromise)} />
</Suspense>
)
}
Con số sau khi đổi
- Chữ đầu tiên hiện: 3.2s → 0.5s
- Bảng đơn hàng dùng được: 1.9s → 0.7s
- Toàn trang render xong: 3.4s → 1.4s
- Query giữ nguyên, không thêm tầng cache nào
Điều mình thích ở cách này là nó sửa bằng cấu trúc, không phải bằng hạ tầng. Trước đó mình cứ nghĩ phải mua thêm Redis hay nâng cấp DB, trong khi vấn đề thật nằm ở chỗ code đang chờ nhau vô ích.
📋 Phụ lục thuật ngữ
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| Server Component | Component chạy trên server, không gửi JS xuống trình duyệt |
| Waterfall | Các tác vụ async chờ nối đuôi nhau thay vì chạy song song |
| Streaming SSR | Gửi HTML thành nhiều đợt, đợt nào xong trước gửi trước |
| Suspense boundary | Ranh giới đánh dấu phần được phép hiện fallback trong lúc chờ |
loading.tsx |
File quy ước của App Router, tự tạo Suspense cho route |
use() |
Hook React 19 đọc Promise ngay trong lúc render |
| TTFB | Thời gian từ lúc gửi request tới byte đầu tiên nhận về |
Nếu app của bạn có một trang trắng lâu hơn hẳn các trang khác, thử liệt kê các await trong page đó rồi tự hỏi: cái nào thật sự cần xong trước, cái nào chỉ đang bị kéo theo? Bọc phần chậm nhất vào một Suspense boundary rồi đo lại — thường chỉ mất mười phút code mà cảm giác tải trang đổi hẳn.
