Trang chủ phatdev.net của mình đã đạt 95 điểm tốc độ trên điện thoại. Nhiều người dừng ở đó. Mình thì thấy từ 95 lên 99 mới là đoạn đáng làm, vì muốn lên phải hiểu điểm số được cộng từ đâu, chứ không dừng ở "làm cho nhanh hơn". Bài này kể những gì mình đổi, số trước và sau, và cả những thứ không cải thiện được.
Bốn chỉ số, nói bằng lời thường
LCP là thời gian để phần lớn nhất của màn hình đầu (thường là ảnh hoặc tiêu đề to) hiện ra. Với người xem, đó là lúc "trang tải xong rồi". Mức tốt là 2,5 giây trở xuống.
INP là độ nhanh của trang sau khi bạn bấm hoặc gõ. Mức tốt là 200 mili giây trở xuống. Chỉ đo được với người dùng thật.
CLS là việc trang có "nhảy" hay không, ví dụ ảnh tải xong đẩy chữ xuống làm bạn bấm nhầm. Mức tốt là 0,1 trở xuống. Site của mình đạt 0.
TBT là tổng thời gian trình duyệt bị kẹt, không phản hồi được, sau khi trang bắt đầu hiện. Đây là chỉ số đo trong phòng thí nghiệm, thay cho INP vì công cụ đo không có người thật để bấm. Mức tốt là dưới 200 mili giây.
Ba chỉ số chính thức của Google là LCP, INP và CLS, tính trên dữ liệu người dùng thật. Còn điểm từ 0 đến 100 mà PageSpeed hiển thị là chuyện khác: nó chạy thử trên một máy giả lập rồi cộng có trọng số các chỉ số.
Giới hạn của bài: mình đo bằng Lighthouse 12.8.2 chạy từ máy chủ của mình, giả lập mạng và CPU điện thoại. Đây không phải dịch vụ PageSpeed Insights của Google và cũng không phải dữ liệu người thật, nên có thể lệch vài điểm so với PSI.
Số trước và sau
Cùng một cách đo, trang /vi, chế độ mobile, trên bản chạy thật:
| Trước | Sau | |
|---|---|---|
| Điểm | 95 | 99 (3 lần đo đều 99) |
| LCP | 2,8 s | 1,81 đến 1,92 s |
| TBT | 100 đến 110 ms | 83 đến 109 ms |
| CLS | 0 | 0 |
| Dữ liệu tải về | khoảng 436 KB | khoảng 194 KB |
Trang /en đi từ 95 hoặc 96 lên 99, một trang giới thiệu dự án đạt 100. Tổng cộng 18 trên 18 lần đo mobile ở các trang chính đạt từ 99 trở lên, desktop 100, và Accessibility, Best Practices, SEO đều 100.
Hai điều cần nói thật. Dung lượng "trước" đo trên cổng nội bộ của bản cũ còn "sau" đo trên bản chạy thật. Và TBT gần như không đổi, nằm trong khoảng nhiễu giữa các lần đo. Điểm tăng chủ yếu nhờ LCP và lượng byte phải tải.

Điều tra: điểm LCP được tính ra sao
Công cụ đo giả lập một điện thoại tầm trung với mạng chậm, rồi ước tính LCP bằng cách cộng thời gian tải mọi thứ cần có trước khi LCP hiện ra. Nghĩa là mỗi byte thừa và mỗi vòng đi về qua mạng nằm trước LCP đều làm điểm tụt. Đọc dữ liệu đo, mình tìm ra các thủ phạm:
- Ảnh avatar nặng khoảng 150 KB nhưng chỉ hiển thị 72 px.
- Hai file font thừa, gồm bộ chữ latin-ext và font tiếng Việt cũng bị nạp trước ở trang tiếng Anh.
- Thư viện hoạt ảnh (framer-motion, khoảng 37 KB) chỉ để làm hiệu ứng hiện dần.
next/link(khoảng 9 KB) ở những chỗ chỉ cần thẻ<a>thường.- HTML của trang chủ khoảng 19 KB sau khi nén gzip, vượt qua lượng dữ liệu máy chủ được phép gửi ngay đợt đầu. Phần này mình giải thích ở dưới.
Mình đã làm gì
Avatar đi qua bộ tối ưu ảnh của Next.js, ra các cỡ 96, 128 và 256 px, chỉ khoảng 3 đến 7 KB thay vì 150 KB. Mình dùng thẻ <img> thường trỏ tới bộ tối ưu này thay vì component next/image, để không kéo thêm JavaScript cho một tấm ảnh nhỏ.
Với font, mình sắp lại thứ tự khai báo để trang tiếng Việt không tải bộ latin-ext, và chỉ nạp trước font tiếng Việt ở /vi. Mỗi file font là một lượt gọi mạng, và chữ phải chờ nó.
Hiệu ứng hiện dần khi cuộn, mình viết lại bằng CSS cộng một IntersectionObserver duy nhất, nên trang chủ không còn tải framer-motion. Ở trang giới thiệu dự án có một chi tiết hay: hiệu ứng mở trang phải bắt đầu ở độ mờ 0.01 chứ không phải 0, vì phần tử hoàn toàn trong suốt khiến công cụ đo không tìm thấy LCP.
Bốn ảnh chụp màn hình đi từ PNG khoảng 0,8 đến 1,1 MB xuống WebP khoảng 90 KB, có width và height, tải lười. Mình chỉ giữ PNG cho ảnh chia sẻ mạng xã hội. Cuối cùng là giảm việc phải vẽ: content-visibility: auto cho các khối dưới màn hình đầu, thay hiệu ứng filter: blur ở màn chờ bằng gradient, chuyển hơn một trăm đoạn style={{...}} tĩnh thành class CSS, và chia các khối dưới hero vào Suspense.
Thay đổi cuối là cái ảnh hưởng nhiều nhất tới LCP mô phỏng: để nginx nén HTML bằng brotli. Lý do nằm ở phần sau.

Vì sao 14,6 KB lại quan trọng
Khi trình duyệt mở kết nối, máy chủ không được gửi ào ào. Nó bắt đầu bằng một "cửa sổ" nhỏ: theo RFC 6928, 10 gói tin, mỗi gói khoảng 1.460 byte, tức khoảng 14,6 KB. Nếu HTML lớn hơn mức đó, phần còn lại phải chờ thêm một vòng đi về qua mạng. Trên mạng di động chậm, mỗi vòng có thể tốn hàng trăm mili giây.
HTML trang chủ của mình là 19 KB sau gzip, nên chắc chắn vượt. Next.js mặc định tự nén gzip. Mình tắt bằng compress: false và để nginx (đã bật brotli) nén. Brotli nén chữ tốt hơn gzip: HTML của /vi từ 19 KB còn 12,2 KB, dưới 14,6 KB, tức vừa trong đợt gửi đầu tiên và bớt được một vòng đi về.
Điều kiện đi kèm: phải có một lớp phía trước thật sự nén, như nginx có brotli hoặc một CDN. Không có thì bạn vừa tắt nén hoàn toàn.
// next.config.mjs
const nextConfig = {
compress: false, // let the reverse proxy (nginx + brotli) compress the HTML
};
export default nextConfig;
Thứ không làm được thêm
Còn lại khoảng 115 ms mô phỏng nằm ở phần khởi động của chính React và Next.js, mình không giảm thêm được bằng code của site. TBT dao động từ 30 tới 110 ms tuỳ máy đang bận hay rảnh lúc đo, nên thỉnh thoảng một lần đo ra 98.
Lỗi hay gặp khi tối ưu tốc độ
Lỗi hay nhất là tin vào một lần đo. Hãy đo ít nhất ba lần và nhìn khoảng dao động. Lỗi thứ hai là tối ưu thứ nằm sau LCP: giảm byte của thứ tải sau đó thường không đổi điểm. Cũng có người tối ưu cho công cụ đo mà quên người dùng; dữ liệu thật và INP mới là thước cuối cùng. Cuối cùng là hai cái bẫy mình đã đụng: tắt nén phía ứng dụng mà không có lớp nén phía trước, và để độ mờ bằng 0 ở phần tử LCP.
Làm gì tiếp theo
Nếu muốn thử trên site của bạn, bắt đầu bằng một việc: mở bản đo, tìm ra thứ nặng nhất nằm trước LCP, và giảm nó. Đo ba lần trước, đổi một thứ, đo ba lần sau. Đừng đổi cùng lúc năm thứ, vì bạn sẽ không biết cái nào có tác dụng.
Phần chỉ mục và dữ liệu có cấu trúc của cùng site nằm ở Làm sao để Google thấy đủ site hai ngôn ngữ của bạn (Next.js).



