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 Suspense thì 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. File app/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() trong Suspense. Đừng useEffect + 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.

Xem các bài viết khác