- ngày ghi
- phút đọc
- 6
- nguồn dẫn
- 8
'Web chậm trên điện thoại' - thủ phạm không phải database hay bundle, mà là 0 file loading.tsx
Đo thật: truy vấn DB 67-266 ms, First Load JS 102 kB, nhưng 0 file loading.tsx trên 23 trang force-dynamic. Cảm giác đơ đến từ khoảng lặng không phản hồi. Sửa bằng 12 skeleton.
Chủ đề: hiệu năng, erp, vận hành
Người dùng trong công ty phản ánh: "web chậm, đặc biệt trên điện thoại". Tôi nghi database trước, rồi nghi bundle JavaScript. Tôi đo trước, sửa sau. Cả hai đều không phải thủ phạm.
Đo trước, đoán sau
Tôi đo ba thứ.
Truy vấn database. Đo từ máy tại Việt Nam: 67-266 ms mỗi truy vấn. Danh sách content đã dùng Promise.all, không có vòng lặp N+1. Mọi truy vấn danh sách đều có .limit() ở mức 100, 500, 1000 hoặc 2000.
Dung lượng JavaScript. Số lấy từ npm run build ngày 29/07/2026:
| Trang | First Load JS |
|---|---|
| Dùng chung cho mọi trang | 102 kB |
/, /content, /work, /me, /workload | 106 kB |
/revenue | 130 kB |
/hr, /projects | 146 kB |
/costs | 167 kB |
/boards/[id] (nặng nhất trong menu) | 190 kB |
/users | 200 kB |
/deals (nặng nhất toàn hệ, đang ẩn khỏi menu) | 222 kB |
100-190 kB là mức bình thường của một ứng dụng React. Không phải chỗ để tối ưu trước.
Số màn hình chờ. Lệnh find web/src -name "loading.tsx" trả về 0 kết quả. Trong khi đó thư mục (app) có 23 file khai báo force-dynamic, tức 23 trang render động mà không có màn hình chờ.
Điểm thứ ba mới là câu trả lời.
Cảm giác đơ đến từ khoảng lặng
App Router chỉ hiện phản hồi tức thì khi segment có loading.tsx để tạo ranh giới Suspense. Không có file đó, bấm chuyển trang xong web giữ nguyên trang cũ. Không spinner. Không khung tải. Cho tới khi server render xong. Trên 4G độ trễ cao, khoảng lặng đó dài 0,5-2 giây. Người dùng nghĩ máy treo nên bấm lại. Càng chậm thêm.
Bên dưới còn một chi phí cố định. Mỗi lần chuyển trang tốn hai vòng gọi tới Supabase Auth: một ở middleware để làm mới token, một trong getServerSession() của trang. Hàm này đã bọc cache(), nhưng vẫn là vòng gọi tách biệt với middleware.
Tệ hơn, matcher của middleware trước đó chỉ loại trừ ảnh. Ba file font .ttf và /manifest.webmanifest vẫn chạy qua middleware, mỗi file kéo thêm một vòng gọi Auth khi mở trang lần đầu.
Độ trễ thật của mỗi vòng gọi Auth từ máy người dùng thì chưa đo. Tôi ghi "chưa đo" kèm cách đo, không chế số.
Sửa: 12 khung xương bám bố cục thật
Nguyên tắc của đợt này: chỉ thêm file mới, không sửa trang nào đang chạy. Một file skeletons.tsx chứa các khối dùng chung, cộng 12 file loading.tsx: một khung mặc định ở (app)/loading.tsx và 11 khung riêng cho content, chi tiết bài content, board, công việc, việc của tôi, dự án, cổng duyệt, điều hành, chi phí, doanh thu, nhân sự.
Cách dựng khung:
- Đọc từng
page.tsxrồi dựng lại đúng số panel, số cột bảng, chiều cao hàng, bề rộng cột kanbanw-[280px], lưới KPI giữmin-height: 118px. Dữ liệu thật về thì không nhảy layout. - Chỉ dùng token màu trung tính, tự đúng ở cả theme sáng lẫn tối.
- Server component thuần, không state, không hook. Bảng build ở trên chạy sau khi thêm skeleton, vẫn đúng dải 102-222 kB, không thêm byte nào.
- Mỗi khung bọc
role="status",aria-busyvà nhãn ẩn "Đang tải dữ liệu…".globals.cssđã tắt animation khi người dùng bật giảm chuyển động. - Ưu tiên đúng 5 tab dock trên điện thoại:
/,/work,/projects,/content,/approvals.
Một đánh đổi tôi chấp nhận: Dashboard không có ranh giới Suspense riêng nên dùng chung khung mặc định, cố tình trung tính, không vẽ dải chào mừng gradient. Muốn khớp hoàn toàn phải tách trang chủ vào route group riêng trên hệ đang chạy, đổi lấy khoảng nửa giây thẩm mỹ. Tôi để sau.
Sửa thứ hai là siết matcher middleware. Thêm các đuôi avif, ico, ttf, otf, woff, woff2, webmanifest, txt, xml, map vào danh sách loại trừ. Hết chuyện ba file font và manifest kéo theo auth.getUser() mỗi cái.
Vì sao an toàn, tôi kiểm từng ý. Cổng đăng nhập thật của khu (app) nằm ở layout.tsx, không nằm ở middleware. Middleware chỉ làm mới token và chặn thêm 4 prefix, cả 4 đều trong (app) nên layout đã gác sẵn. Chạy regex mới trên danh sách đường dẫn thật: mọi đường dẫn ứng dụng vẫn qua middleware như cũ.
Tôi không siết /api/*, dù cả 9 route API đều tự kiểm phiên bên trong. Bỏ middleware ở đó có rủi ro đăng xuất bất chợt khi hai request cùng xoay refresh-token. Chưa làm.
Vì sao tôi từ chối bỏ force-dynamic
Đề xuất dễ nghe nhất là bỏ force-dynamic hoặc bật cache. Trang sẽ nhanh lên thật. Nhưng với trang có dữ liệu theo người dùng (/, /work, /me, /content, /approvals, /workload, /hr, /revenue, /costs, /boards/*), framework sẽ dùng chung bản render đã cache cho nhiều người. Người này thấy dữ liệu của người kia. Với ERP đang chạy thật, đó là sự cố dữ liệu, không phải lỗi hiệu năng. Đợt này không đụng cache của bất kỳ trang nào.
Tương tự với ý gộp hai vòng gọi Auth bằng cách để middleware ghi kết quả vào header rồi trang đọc lại. Tin vào header mà không xác thực lại là mẫu từng gây lỗ hổng bỏ qua đăng nhập. Chỉ cân nhắc khi đo được độ trễ Auth trên 300 ms, và có người review riêng phần bảo mật.
Đo lại thế nào để biết có nhanh thật
Không đo bằng localhost, vì localhost không có độ trễ mạng. Cách đo: Chrome, tab Network, throttling Fast 4G, bấm lần lượt Công việc, Dự án, Content, Duyệt và nhìn TTFB.
TTFB gần như không đổi, vì skeleton không làm server nhanh hơn. Thời gian từ lúc bấm đến lúc màn hình đổi mới giảm: trước bằng đúng TTFB, sau còn một khung hình, khoảng 16 ms.
Kiểm sau sửa: npm run typecheck sạch, 0 lỗi. npm run build thành công, First Load JS không tăng. Tôi xem trên bản next start ở khung nhìn 375×812, làm chậm fetch thêm 5 giây để giữ màn hình chờ. Khung hiện đúng bố cục trang đích. Thanh tiêu đề và dock đổi ngay khi bấm. Không tràn ngang, scrollWidth bằng clientWidth bằng 375. Bảng Content rộng 980 px cuộn bên trong khung của nó.
Còn để lại hai việc rẻ tiền: logo 600×299 px nặng 146 kB nhưng hiển thị ở 30 px chiều cao, và 3 font TTF tổng 841 kB chưa chuyển sang WOFF2. Đợt sau.
Điều tôi rút ra
- "Chậm" là cảm nhận, phải đo ra số trước khi chọn chỗ sửa. Tôi suýt tối ưu database trong khi database chỉ mất 67-266 ms.
- Chỗ sửa an toàn nhất thường là chỗ chỉ thêm file mới. 12 file
loading.tsxkhông đổi hành vi trang nào, không thêm byte nào. - Nhanh hơn không được đổi bằng rủi ro lộ dữ liệu giữa người dùng. Cache và
force-dynamiclà quyết định của chủ hệ thống. - Việc chưa đo thì ghi "chưa đo", kèm cách đo. Không điền số cho đẹp.
Nếu công ty bạn cũng đang nghe câu "web chậm" mà chưa biết chậm ở đâu, ta nói chuyện 15 phút.
Bài này chạm đúng việc bạn đang làm? Mười lăm phút nói chuyện không mất gì.