Vì sao chuyển trang trong Next.js vẫn chậm dù máy chủ phản hồi nhanh
Tôi đã dùng Next.js khá lâu mới giải thích được vì sao đôi khi bấm vào một liên kết mà màn hình vẫn đứng yên trong chốc lát.
Máy chủ không xử lý tác vụ nào quá nặng. Trình duyệt cũng đã tải rất nhiều JavaScript. Vậy mà màn hình tiếp theo vẫn phải chờ một lượt gửi yêu cầu và nhận phản hồi qua mạng.
Thứ còn thiếu là payload React Server Component của route đích. Khi hiểu Next.js tải payload đó vào lúc nào và có thể dùng lại những phần nào, tôi mới dễ giải thích và xử lý độ trễ này hơn.
Đây là bài đầu tiên trong loạt bài của tôi về hiệu năng Next.js. Các ví dụ đề cập đến App Router, mô hình cache theo route trước đây, cùng các tùy chọn cacheComponents và partialPrefetching có trong Next.js 16.3 trở đi.
Chỉ có JavaScript vẫn chưa đủ
Với ứng dụng render ở phía client, trình duyệt có thể dựng màn hình đích ngay khi có JavaScript tương ứng. Dữ liệu có thể vẫn đang tải, nhưng ứng dụng đã có thể hiển thị giao diện chờ của riêng mình.
Server Components làm thay đổi những gì trình duyệt có sẵn. Phần triển khai của chúng nằm trên máy chủ. Trình duyệt nhận kết quả render qua RSC payload, cùng các tham chiếu đến Client Components và props mà những component đó cần.
Payload này là biểu diễn đã tuần tự hóa của cây component. Nó không đơn thuần là một tài liệu HTML, cũng không phải mã nguồn của Server Components.
Xem một ví dụ nhỏ:
// app/posts/page.tsx
import { LikeButton } from './like-button'
export default function PostsPage() {
return (
<main>
<h1>Posts</h1>
<LikeButton />
</main>
)
}
// app/posts/like-button.tsx
'use client'
import { useState } from 'react'
export function LikeButton() {
const [liked, setLiked] = useState(false)
return (
<button onClick={() => setLiked(!liked)}>
{liked ? 'Liked' : 'Like'}
</button>
)
}
PostsPage vẫn là Server Component dù không có async, await hay truy vấn cơ sở dữ liệu. Page và layout trong App Router mặc định là Server Components. Chỉ thị 'use client' xác định ranh giới client; các component được import qua ranh giới đó thuộc đồ thị module phía client.
Khi nghĩ về việc chuyển trang, tôi thường hình dung trình duyệt đã có một phần cây component của trang đích, còn một số chỗ phải nhận kết quả từ máy chủ. Câu hỏi quan trọng là kết quả đó đến trước hay sau khi người dùng bấm.
Nếu thiếu payload cần thiết, quá trình chuyển trang có thể phải chờ máy chủ. Tuy nhiên, một loading boundary hoặc khung giao diện tĩnh đã được tải trước vẫn có thể phản hồi ngay. Chỉ riêng việc có Server Component không khiến mọi lần chuyển trang đều chậm.
Link prefetch thực sự làm gì
Next.js có thể tải dữ liệu route trước khi chuyển trang. Trong môi trường production, một <Link> xuất hiện trong viewport thường kích hoạt prefetch. Khi người dùng bấm, trình duyệt có thể dùng lại payload đó. Next.js tắt prefetch tự động trong môi trường development, nên chạy next dev không phản ánh đúng hành vi này trên production.
Khi chưa bật Partial Prefetching, hành vi mặc định tùy thuộc vào route:
| Route đích | Hành vi prefetch mặc định |
|---|---|
| Route render tĩnh | Tải trước toàn bộ route |
| Route render động | Tải trước đến loading boundary loading.js gần nhất, nếu có |
prefetch={true} yêu cầu tải trước đầy đủ hơn, kể cả với route động trong mô hình cũ. Nó có thể giảm thời gian chờ sau cú bấm, nhưng cũng khiến máy chủ làm thêm việc trước khi người dùng quyết định truy cập. Khi bật Partial Prefetching, quy tắc lại khác: prefetch mặc định tập trung vào App Shell dùng chung. Tài liệu Link của Next.js
Trình tự cơ bản là:
Liên kết xuất hiện trong màn hình
→ tải trước nội dung route có thể dùng lại
→ giữ trong Client Cache của trình duyệt
Người dùng bấm
→ render nội dung đã cache
→ yêu cầu những phần còn thiếu
Làm việc sớm hơn, trước cú bấm, đôi khi cải thiện cảm giác nhanh hơn cả việc giảm vài mili giây render ở máy chủ.
Dynamic segment khác với dynamic rendering
Từ “dynamic” dễ gây nhầm lẫn ở đây.
Thư mục như posts/[slug] định nghĩa một đoạn URL động. Chỉ riêng cấu trúc đó không có nghĩa là mỗi lượt truy cập đều phải render tại thời điểm nhận request. generateStaticParams có thể cung cấp các giá trị để Next.js prerender.
Dynamic rendering là render tại thời điểm nhận request. Trong mô hình cache trước đây, việc đọc dữ liệu gắn với request như cookies() hoặc headers() khiến route chuyển sang render động. Lệnh đọc đó có thể nằm rất sâu trong cây component.
Hãy hình dung một trang bài viết phần lớn là nội dung tĩnh, chỉ có một nhóm điều khiển nhỏ dành riêng cho người dùng:
Trang bài viết
├─ Tiêu đề
├─ Nội dung
└─ Điều khiển cá nhân
└─ đọc cookies()
Nếu không dùng Cache Components hoặc một thiết lập partial prerendering khác, việc đọc dữ liệu riêng của request sẽ ảnh hưởng đến chế độ render của route. Tiêu đề và nội dung có thể rất dễ render, nhưng route không còn một phản hồi hoàn toàn tĩnh dùng chung cho mọi request. Dữ liệu đã cache và layout dùng chung vẫn có thể được tái sử dụng; “dynamic” không có nghĩa là mọi cache đều biến mất. Mô hình cache trước đây của Next.js
Suspense chỉ phản hồi ngay khi trình duyệt đã có fallback
Bọc nhóm điều khiển cá nhân bằng <Suspense> cho phép máy chủ stream nội dung xung quanh và fallback trước khi phần chậm hoàn thành.
Nhưng trong mô hình cũ, một Suspense boundary thông thường bên trong trang động không tự biến phần xung quanh thành khung giao diện tạo sẵn lúc build. Trình duyệt có thể vẫn phải đợi phản hồi đầu tiên từ máy chủ mới biết cần render gì.
Bấm liên kết
→ yêu cầu payload của route đích
→ nhận phản hồi đầu tiên
→ hiển thị nội dung xung quanh và fallback
→ stream phần nội dung còn lại
Một boundary loading.tsx đã được prefetch có thể khiến bước chuyển ban đầu diễn ra ngay. Nếu fallback chỉ nằm trong payload chưa được tải của trang đích, trình duyệt chưa thể làm vậy. Đó là điểm tôi đã bỏ sót khi nghĩ: “Mình đã dùng Suspense rồi, sao chuyển trang vẫn khựng lại?” Hướng dẫn chuyển trang tức thì của Next.js
Khoảng cách mạng khiến sự khác biệt này rõ hơn. Khi triển khai một dịch vụ trên Cloudflare, đôi lúc tôi thấy lưu lượng từ Hàn Quốc đi đến Los Angeles hoặc Hong Kong. Dù máy chủ render nhanh, nó cũng không loại bỏ được lượt đi về qua mạng đó.
Hãy hiểu staleTimes theo quy tắc dùng lại page
Cấu hình thử nghiệm staleTimes điều khiển việc dùng lại các page segment trong Client Cache:
// Defaults in the previous caching model
const nextConfig = {
experimental: {
staleTimes: {
dynamic: 0,
static: 300,
},
},
}
export default nextConfig
Nếu hiểu tên tùy chọn quá sát mặt chữ thì rất dễ nhầm. Khoảng thời gian dynamic áp dụng cho page vừa không được tạo tĩnh, vừa không được prefetch đầy đủ. Khoảng thời gian static cũng áp dụng cho page đã được prefetch đầy đủ. Loading boundary dùng thời gian static, layout dùng chung có thể tiếp tục được tái sử dụng, còn thao tác quay lại/tiến tới của trình duyệt có hành vi cache riêng.
Vì vậy, dynamic: 0 không có nghĩa là “Next.js không bao giờ giữ lại bất cứ thứ gì của route động”. Nó giải thích một lý do cụ thể khiến một lần truy cập lại thông thường có thể phải yêu cầu page segment lần nữa. Giá trị mặc định này đổi từ 30 giây xuống 0 trong Next.js 15. Tài liệu staleTimes của Next.js
Cache Components tạo khung giao diện có thể dùng lại
Với Cache Components, Next.js có thể prerender một khung giao diện, còn nội dung gắn với từng request nằm sau các Suspense boundary:
// next.config.ts
const nextConfig = {
cacheComponents: true,
}
export default nextConfig
Trình tự chuyển trang lúc này khác đi:
Trước khi bấm
→ tải trước khung tĩnh và các fallback
Sau khi bấm
→ hiển thị khung đó ngay
→ stream nội dung riêng của request vào khung
Bây giờ, tôi đặt boundary quanh phần thực sự cần thông tin tại thời điểm request, thay vì mặc định rằng cả trang phải đợi.
Bật tùy chọn này cũng thay đổi cách ứng dụng khai báo cache. Công việc có thể dùng lại có thể dùng 'use cache'; phần không cache cần stream thì phải có Suspense boundary phù hợp. Với ứng dụng sẵn có, áp dụng mô hình này có thể đòi hỏi nhiều hơn một thay đổi trong file cấu hình. Hướng dẫn cache của Next.js
Tôi rút ra điều đó từ một lần chuyển đổi khá vất vả. Khi dùng Next.js 16.2 trên Cloudflare/OpenNext, tôi bật rồi tắt tùy chọn này trong lúc xử lý tương thích middleware, đọc thông tin xác thực trong layout và hành vi chuyển trang. Sau khi đổi nơi hosting, tôi mới bật nó lại. Từ đó, tôi xem đây là một đợt chuyển đổi ứng dụng, không phải một công tắc tăng tốc miễn phí.
Còn một chi tiết về vòng đời component cần chú ý. Next.js có thể giữ lại các màn hình vừa truy cập bằng React Activity. Activity ẩn DOM và giữ state, đồng thời dọn dẹp Effects. Nếu code giả định rằng rời route luôn hủy DOM, cần kiểm tra lại giả định đó, nhất là với phần tử media và những tác vụ gắn trực tiếp với DOM. Tài liệu React Activity
Partial Prefetching dùng chung App Shell giữa các URL
Next.js 16.3 bổ sung một tùy chọn khác cần chủ động bật:
const nextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfig
Một danh sách bài viết có thể chứa các liên kết /posts/1, /posts/2 và /posts/3. Dù nội dung khác nhau, những URL này có thể dùng chung cấu trúc khung giao diện. Partial Prefetching cho phép Next.js dùng lại App Shell giữa các liên kết có cùng mẫu route, thay vì xem mỗi URL là một đối tượng prefetch hoàn toàn riêng biệt. Thông báo Next.js 16.3
Vị trí đọc thông tin phụ thuộc URL cũng quan trọng. Nếu await params ngay đầu page, nhiều phần render hơn sẽ phụ thuộc vào URL cụ thể đó. Truyền promise xuống dưới và đọc nó bên trong Suspense boundary sẽ giữ được nhiều giao diện độc lập với URL hơn trong shell dùng chung.
Prefetch mặc định lúc này tải shell; nội dung riêng của URL có thể stream sau khi chuyển trang. Nếu một liên kết cần tải sớm hơn, prefetch={true} còn có thể tải trước nội dung đã cache nhưng phụ thuộc URL. Đây là sự đánh đổi giữa làm thêm việc từ sớm và chuyển trang nhanh hơn. Áp dụng Partial Prefetching
Service worker cần một thiết kế cache riêng
Tôi còn dùng Serwist để lưu một số tài nguyên lâu hơn cache route trong bộ nhớ trình duyệt. Tài nguyên tĩnh là điểm khởi đầu dễ thấy. Lưu lâu dài phản hồi RSC cần thận trọng hơn nhiều vì dạng phản hồi có thể phụ thuộc vào request header, trạng thái route, bản triển khai và thông tin xác thực.
Trong triển khai ban đầu, tôi dùng response header của Next.js để phân biệt phản hồi và áp dụng các quy tắc cache riêng. Tôi không xem đoạn code đó là công thức chung cho ứng dụng khác. Bỏ _rsc khỏi cache key hoặc cache phản hồi cá nhân hóa mà không giữ các khác biệt liên quan có thể khiến ứng dụng dùng lại sai phản hồi.
Với đa số ứng dụng, tôi sẽ xử lý prefetch của framework và App Shell trước, rồi chỉ thêm service worker khi có yêu cầu offline hoặc lưu trữ lâu dài cụ thể. Hướng dẫn CDN caching của Next.js, Serwist
Danh sách đã chứa một phần của trang chi tiết
Cải thiện cuối cùng trong ứng dụng của tôi đến từ cách thiết kế màn hình.
Khi người dùng mở một mục từ danh sách, trình duyệt đã biết tiêu đề, thumbnail và phần tóm tắt của mục đó. Thay tất cả bằng một spinner toàn trang là bỏ phí thông tin hữu ích đang có sẵn.
Với phần showcase trong dịch vụ của mình, tôi dùng Intercepting Routes để phân biệt hai cách truy cập cùng một URL:
| Cách truy cập | Nội dung người dùng thấy |
|---|---|
| Chuyển từ danh sách sang | Hiển thị tóm tắt đã cache ngay, rồi tải phần chi tiết còn lại |
| Truy cập trực tiếp hoặc tải lại trang | Render toàn bộ chi tiết qua route máy chủ thông thường |
Route được intercept dùng state phía client sẵn có của danh sách làm nội dung ban đầu. Nếu không tìm thấy phần tóm tắt tương ứng, nó hiển thị skeleton. Route thông thường xử lý việc tải dữ liệu từ máy chủ và metadata cho lượt truy cập trực tiếp.
Intercepting Routes giúp phân biệt hai cách điều hướng; state phía client dùng chung mới cung cấp nội dung tức thì. Chỉ dùng Intercepting Routes không bảo đảm rằng quá trình chuyển trang sẽ không có request RSC nào. Intercepting Routes của Next.js
Bắt đầu từ khoảng chờ sau cú bấm
Tôi vẫn thích Next.js. Những thay đổi này xuất phát từ quá trình dùng nó để xây Comwit, dịch vụ của tôi, và mong muốn chuyển trang mượt hơn.
Khi một lần chuyển trang có vẻ chậm, giờ tôi xem ba thứ: trình duyệt đã có gì, nó còn cần gì từ máy chủ, và nó có thể hiển thị gì trong lúc chờ request.
Server Component không tự động làm chuyển trang chậm. Điều tôi kiểm tra đầu tiên là ứng dụng có đợi đến lúc người dùng bấm mới lấy giao diện cần cho màn hình tiếp theo hay không.
Chuyển thể từ bài gốc tiếng Hàn của tôi trên Velog, đăng ngày 27 tháng 9 năm 2026.
Liên hệ: X @meursyphus.
All rights reserved