GĐ27 — Frontend Performance: Core Web Vitals, Web Workers, Service Workers & Offline UX
Giai đoạn này hướng dẫn đo trải nghiệm frontend trên thiết bị thật, tìm nút thắt theo loại metric rồi sửa đúng lớp. Web Worker và Service Worker giải quyết hai bài toán khác nhau; chúng không phải mẹo tự động làm site nhanh.
Kiểm chứng ngày 2026-10-05: code mới ở GĐ này được cài và chạy trong thư mục tạm (không nằm trong repo) với Chrome 154 thật điều khiển bằng Playwright 1.63,
web-vitals6.2, Workbox 7.4 (workbox-build), Comlink 4.4, Vite 8.3, React 19.3,size-limit14.1, TypeScript 6.0 và Node 24. Nguồn tra cứu: web.dev Core Web Vitals, web-vitals, MDN Scheduler.yield, Workbox. Mỗi mục ghi rõ "Đã chạy" hay "Code tham chiếu, chưa chạy". Hỗ trợscheduler.yield()của từng trình duyệt thay đổi theo thời gian: kiểm bảng tương thích của MDN trước khi dựa vào nó (Safari: chưa xác minh).
1. Kết quả cần đạt#
- Đọc Core Web Vitals, phân biệt field data với lab data, metric người dùng thấy với metric chẩn đoán.
- Dùng waterfall và profile để tách vấn đề LCP, INP, CLS thành nguyên nhân cụ thể.
- Tối ưu ảnh, JavaScript, CSS, fonts, layout, API và cache theo số đo trước/sau.
- Offload phép tính CPU nặng sang dedicated Web Worker mà không chặn main thread.
- Hiểu structured clone, transferable objects, cancellation và overhead của worker.
- Thiết kế Service Worker cache/offline strategy theo loại request, version, auth và dữ liệu nhạy cảm.
- Kiểm tra online/offline, cache cold/warm, SW update, rollback, quota/storage eviction và logout.
- Viết performance budget cho một route và theo dõi regression thực tế ở 75th percentile.
2. Trải nghiệm web gồm nhiều loại tốc độ#
Một trang có thể tải HTML nhanh nhưng thao tác chậm; content có thể hiện ngay nhưng layout nhảy; lab desktop có thể tốt nhưng mobile thật rất tệ. Trước khi tối ưu, hãy xác định user đang chờ điều gì:
| Triệu chứng | Câu hỏi đầu tiên |
|---|---|
| Trang trống lâu | HTML/TTFB chậm, render-blocking CSS/JS hay LCP resource bị phát hiện muộn? |
| Content hiện nhưng nút lag | JS chạy lâu trên main thread? Event handler, framework render, layout hay third-party script? |
| Nội dung nhảy khi tải | Ảnh/font/ads/embed chưa dành sẵn không gian? Có content chèn trước nội dung đang xem? |
| Lần sau nhanh, lần đầu chậm | Cache, service worker, browser cache hay CDN? Cold path có đạt mục tiêu? |
| Chỉ một phần user chậm | Thiết bị, mạng, vị trí, route, locale, account state hay experiment nào? |
Không bắt đầu bằng “đổi framework” hay “viết lại bằng Rust”. Trước hết thu baseline tái lập được ở đúng route, thiết bị, network và trạng thái cache.
3. Core Web Vitals và cách đo#
Core Web Vitals hiện tập trung vào tải nội dung, tương tác và ổn định bố cục. Ngưỡng “good” xét ở 75th percentile, tách mobile và desktop:
| Metric | Đo gì? | Mục tiêu good |
|---|---|---|
| LCP — Largest Contentful Paint | Khi phần tử nội dung lớn nhất trong viewport được render | ≤ 2.5 giây |
| INP — Interaction to Next Paint | Độ trễ tương tác từ input tới lần paint tiếp theo trong vòng đời trang | ≤ 200 ms |
| CLS — Cumulative Layout Shift | Mức độ phần tử nhìn thấy dịch chuyển bất ngờ | ≤ 0.1 |
FID đã được thay bằng INP trong bộ Core Web Vitals hiện hành. Đừng đặt KPI mới theo bài cũ dùng FID.
Field data và lab data#
- Lab (Chrome DevTools, Lighthouse, WebPageTest) tạo điều kiện kiểm soát để tìm regression và so sánh thay đổi. Nó tái hiện một thiết bị/network giả lập, không đại diện mọi khách hàng.
- Field/RUM đo session thật, thấy thiết bị/mạng và hành vi mà lab không tái tạo. Nó giúp xác định bao nhiêu user/route đang gặp vấn đề.
- CrUX là tập dữ liệu người dùng thật của Chrome có tiêu chí đủ dữ liệu; có thể không có dữ liệu cho trang traffic thấp.
Dùng lab để khoanh nguyên nhân trong lúc phát triển; dùng field data để quyết định trải nghiệm production có cải thiện không. Đừng kết luận chỉ từ một Lighthouse score.
Ví dụ có đáp án: lab tốt, field kém
Tình huống: Lighthouse trên laptop báo LCP ở mức "good" (dưới 2,5 s) nhưng báo cáo field p75 mobile của route đó ở mức "poor" (trên 4,0 s). Nên tin số nào và làm gì tiếp?
Đáp án. Tin field để kết luận "có vấn đề" (đó là người dùng thật), dùng lab để tìm nguyên nhân. Hai số lệch nhau là bình thường vì lab chỉ mô phỏng một thiết bị, một mạng, một trạng thái cache. Các nguyên nhân hay gặp: thiết bị thật yếu hơn mức throttle, mạng di động dao động, cache lạnh so với cache ấm, người dùng đã đăng nhập thấy trang khác trang ẩn danh, TTFB xa CDN, banner/consent tạo ra phần tử LCP khác ở một số cohort.
Kết quả mong đợi: sau khi sửa, kiểm bằng field (cần đủ thời gian để p75 cập nhật), không chỉ bằng điểm Lighthouse. Sai thường gặp: chạy lại Lighthouse đến khi xanh rồi kết luận đã xong; gộp mobile với desktop làm p75 trông ổn. Xem mục 3 (cách gửi RUM) và mục 4.
Thu Core Web Vitals trong RUM#
Đo bằng web-vitals library để dùng implementation khớp với báo cáo Web Vitals. Ví dụ chỉ gửi field metric cần thiết, không gửi URL query có thể chứa PII:
Trong hệ thống thật:
- Gửi metric ở sample rate phù hợp và có consent/privacy policy tương ứng.
- Không gửi email, token, nội dung form, raw query string hoặc định danh không cần thiết.
- Gắn release/build ID, route template, device class và navigation type để so sánh đúng cohort.
- Aggregate p75 theo route/device/time window; đừng lấy trung bình toàn site để che route xấu.
- Dùng endpoint intake có rate limit, validation và sampling; không để endpoint RUM thành nguồn chi phí không giới hạn.
Thêm attribution để biết pha nào chậm#
Số LCP/INP một mình chỉ nói "chậm"; bản web-vitals/attribution trả thêm các pha. Chỉ gửi những trường chẩn đoán cần thiết, không gửi attribution.url (có thể chứa query):
Đã chạy (Chrome 154, ba route thử, mỗi route 6 lần tải, RUM bật 100% trong lab): route có ảnh hero do JS chèn muộn báo loadDelay khoảng 510 ms, còn ảnh có sẵn trong HTML báo loadDelay khoảng 4 ms (cùng loadDuration khoảng 300 ms do server giả trễ). Với INP, route có handler chạy đồng bộ báo processing khoảng 500 ms, route có handler chia lát báo khoảng 51 ms. Đây là lý do cần attribution: nó chỉ ra pha cần sửa (phát hiện muộn hay xử lý JS) thay vì đoán.
Endpoint intake và tính p75#
Endpoint RUM nhận dữ liệu từ trình duyệt của người lạ nên phải coi là input không tin cậy: giới hạn kích thước, kiểm kiểu và khoảng giá trị, chỉ chép trường đã biết, rate limit theo IP.
Phần còn lại của handler: từ chối req.method !== 'POST', ngắt khi body vượt 4 KiB, trả 429 khi một IP gửi quá 120 lần mỗi phút, trả 400 nếu parse ra null, 204 khi ghi được. Tổng hợp theo route, thiết bị và phiên bản, rồi lấy p75 bằng nearest-rank (phần tử ở vị trí ceil(0.75 * n) sau khi sắp xếp):
Đã chạy (Node 24 chạy trực tiếp file TypeScript, Chrome gửi beacon thật): 54 bản ghi hợp lệ được ghi; p75 theo route|device|appVersion|name ra LCP 820 ms với route ảnh chèn muộn và 316 ms với ảnh trong HTML; INP 528 ms (xếp poor, trên 500 ms) với handler đồng bộ và 80 ms với handler chia lát. Mẫu chỉ 6 lần mỗi route trên một máy, nên đây là minh họa quy trình, không phải benchmark. Đường 400/429/body quá lớn suy ra từ code, chưa gửi payload xấu.
4. LCP — tìm phần tải làm nội dung chính xuất hiện muộn#
LCP thường là hero image, poster, heading block hoặc hình lớn trong viewport. LCP chậm có thể đến từ:
- HTML/document response bắt đầu muộn (server/TTFB).
- Browser phát hiện LCP resource muộn vì nó chỉ xuất hiện trong CSS/JS sau hydration.
- Resource tải lâu vì ảnh nặng, network chậm hoặc priority thấp.
- Resource tải xong nhưng render bị trì hoãn bởi JS, CSS/font hoặc main-thread work.
Triage bằng DevTools#
- Chọn đúng URL và viewport mobile; clear cache/site data để đo cold load.
- Bật network/CPU throttling thực tế; reload và xác định phần tử LCP trong Performance/Lighthouse.
- Đọc waterfall: document → stylesheet/script → LCP resource → font → render.
- Dùng trace kiểm tra main-thread tasks, style/layout và image decode trước LCP.
- Sửa một nguyên nhân, chạy lại cùng điều kiện và ghi lại LCP cùng bytes/request count.
Cải thiện thường hiệu quả#
- Đảm bảo LCP image được phát hiện từ HTML, không chờ effect/hydration mới gán
src. - Không lazy-load ảnh đang là hero/LCP. Lazy load hợp với ảnh xa viewport.
- Dùng
srcset/sizesđể gửi kích thước hợp viewport; nén/đổi format theo nội dung và browser support. - Chỉ preload resource quan trọng khi browser chưa phát hiện nó sớm; preload sai tranh bandwidth với CSS/font/LCP.
- Đặt
width/heighthoặcaspect-ratiocho ảnh/video để tránh CLS. - Giảm render-blocking CSS, JS chặn parser và third-party script trong critical path.
- Nếu TTFB chiếm phần lớn, tối ưu cache/server/CDN/API/SSR trước khi chỉnh CSS.
Không preload tất cả ảnh/font. Mỗi preload là ưu tiên cao tranh băng thông; xác minh bằng waterfall rằng nó cải thiện LCP.
Bằng chứng: ảnh phát hiện muộn so với ảnh có trong HTML#
Hai route cùng một ảnh hero 800x450, cùng server giả trễ 300 ms khi trả ảnh:
| Route | Cách có ảnh | LCP p75 | loadDelay | loadDuration |
|---|---|---|---|---|
| Ảnh do JS chèn sau 500 ms | img.src gán trong script | 820 ms | khoảng 510 ms | khoảng 300 ms |
| Ảnh trong HTML | <img> có sẵn | 316 ms | khoảng 4 ms | khoảng 300 ms |
Đã chạy (Chrome 154, 6 lần mỗi route, một máy): toàn bộ chênh lệch nằm ở pha phát hiện (loadDelay), không phải ở tải hay render. Sửa đúng pha là đưa ảnh vào HTML; khi framework bắt buộc chèn muộn thì báo trước cho trình duyệt:
Streaming SSR và bfcache#
Khi TTFB hoặc "đợi mọi dữ liệu mới gửi HTML" là nút thắt, streaming SSR gửi phần vỏ trang trước và phần chậm sau trong cùng response. React 19: renderToPipeableStream, bọc phần chậm trong Suspense với fallback.
Đã chạy (Node 24, react-dom/server 19.3): một component chờ dữ liệu 300 ms bọc trong Suspense. onShellReady kích hoạt sau khoảng 3 ms và chunk đầu đã chứa <h1>Shop</h1> cùng fallback Đang tải…; chunk thứ hai tới khoảng 300 ms mang nội dung thật và script thay thế vị trí fallback. Chưa chạy qua proxy hay CDN thật: bộ đệm đứng giữa có thể gom response lại và vô hiệu hóa streaming, nên kiểm bằng curl -N ở môi trường thật.
Back/forward cache (bfcache) cho phép quay lại trang gần như tức thì nếu trang đủ điều kiện. Tránh handler unload, đóng kết nối/timer không cần khi pagehide, và khi trang được khôi phục thì làm mới dữ liệu có thể đã cũ:
Điều kiện đủ/không đủ thay đổi theo trình duyệt và phiên bản. Chưa chạy trong lab này; kiểm trong DevTools, Application, "Back/forward cache" trước khi kết luận.
5. INP — giảm công việc chặn tương tác#
INP nhìn vào các tương tác trong session, không chỉ click đầu tiên. Một tương tác gồm thời gian chờ event handler, chạy callback và chờ browser cập nhật frame. Nút “đã nhận click” nhưng visual feedback phải đợi parse file JSON lớn vẫn tạo cảm giác lag.
Thứ tự xử lý#
- Tìm interaction chậm trong field data/session replay/Performance trace.
- Tách thời gian chờ input, event handler, framework rendering và style/layout.
- Sửa code synchronous: giảm vòng lặp, tránh serialize/parse data dư, tránh render lại cả cây.
- Chia task dài thành chunk để browser xử lý input/paint giữa các phần.
- Nếu phép tính CPU độc lập với DOM, chuyển sang Worker sau khi đo overhead message/clone.
- Chỉ debounce/throttle khi semantics nghiệp vụ cho phép; chúng cũng có thể làm tương tác cảm giác chậm.
Ví dụ chia việc theo batch để nhường main thread:
setTimeout(0) minh họa chia việc, không phải API scheduling tối ưu cho mọi browser. Chọn scheduler.yield()/scheduler API nếu browser matrix hỗ trợ và xác minh bằng trace.
scheduler.yield, useDeferredValue và số đo#
scheduler.yield() nhường main thread nhưng giữ ưu tiên của tác vụ đang chạy, nên phần việc còn lại không bị xếp sau mọi task khác như setTimeout. Không phải trình duyệt nào cũng có (Chrome/Edge 129 trở lên, Firefox 142 trở lên; Safari chưa xác minh), nên luôn kiểm tính năng và có đường lui:
Handler phải cập nhật giao diện trước khi vào vòng nặng (ví dụ đặt "Đang xử lý…") để frame đầu tiên được vẽ sớm. Đã chạy (Chrome 154, 3.000 phần tử, mỗi phần tử khoảng 0,1 ms): handler đồng bộ cho INP p75 528 ms (processing khoảng 500 ms); chia lát 50 ms cho INP p75 80 ms (processing khoảng 51 ms). Chrome 154 có scheduler.yield; nhánh setTimeout suy ra từ code, chưa chạy ở trình duyệt thiếu API.
Với React, cập nhật không khẩn cấp có thể hoãn bằng useDeferredValue: ô nhập luôn phản hồi ngay, danh sách nặng render theo giá trị đã trễ.
Đã qua tsc --strict (đã chạy, sạch); chưa đo INP với nó. Chỉ có tác dụng khi phần nặng nằm trong render của React; nếu nút thắt là hàm tính thuần thì dùng chia lát hoặc Worker (mục 8).
Nguyên nhân dễ bị bỏ sót#
- Listener trên
documentxử lý event dù route/component không còn cần. - Một input cập nhật state toàn trang; hãy xem flame chart trước khi thêm memoization đại trà.
- Layout thrashing: đọc
getBoundingClientRect()và ghi style xen kẽ trong loop. - Search/sort hàng chục nghìn row mỗi keystroke.
- JSON response khổng lồ và parse synchronous.
- Third-party analytics/chat/ads cạnh tranh main thread.
- Font swap, image decode và layout làm presentation delay tăng.
6. CLS — giữ chỗ trước khi nội dung tải#
Layout shift thường đến từ ảnh/video không có kích thước, ad/embed/banner được chèn phía trên content, font web thay fallback có metrics khác, client render sau hydration hoặc skeleton khác kích thước final.
- Khai báo
width/heightcho<img>và dùng CSS responsivemax-width: 100%; height: auto. - Dành sẵn kích thước cho ad, embed và async widget; xác định UX nếu slot không có nội dung.
- Không chèn content phía trên viewport trừ khi người dùng thực hiện action.
- Chọn fallback font gần metrics, subset font, preload font critical và đặt
font-displaycó lý do. - Giữ skeleton gần kích thước final; tránh tạo layout giả quá lâu.
- Đo trên mạng chậm, khi font/image cache lạnh; trang warm-cache có thể che CLS.
Font fallback hiệu chỉnh để giảm dịch chuyển khi font web thay thế (code tham chiếu, chưa chạy; số size-adjust phải đo theo font của bạn):
7. Bundle, network và browser cache#
JavaScript#
- Chia theo route/feature và tải phần hiếm dùng bằng dynamic import.
- Dùng bundle analyzer phát hiện dependency lớn/trùng; chỉ thay package sau khi so sánh API, size và maintenance.
- Tree-shaking cần ESM và side-effect metadata đúng; import nhỏ về cú pháp chưa chắc bundle nhỏ.
- Tránh micro-splitting từng file thành hàng trăm request; code splitting cũng có request/lifecycle overhead.
- Chỉ prefetch route nếu xác suất sử dụng và network budget cho phép.
- Đo cold-cache bytes, parse/compile/evaluate time và long tasks, không chỉ gzip size.
Images, fonts, CSS#
- Dùng responsive images,
sizes, width/height; lazy-load content dưới fold. - Inline critical CSS chỉ khi số đo chứng minh lợi ích; CSS lớn cũng cần tải/parse.
- Purge CSS không dùng, tránh tạo CSS runtime khổng lồ cho từng request.
- Chỉ preload vài font/weight quan trọng; dùng fallback đúng cho Vietnamese glyphs.
- Ưu tiên system font nếu font thương hiệu không tạo đủ giá trị để bù tải.
Caching#
- File build có content hash: cache dài hạn,
immutable. - HTML/manifest trỏ asset mới: cache ngắn hoặc revalidate.
- User-specific API: không public-cache; chọn
private/no-storetheo sensitivity và thiết kế auth. - Giữ overlap asset cũ đủ lâu để HTML/tab cũ không gọi file đã bị xóa.
HTTP cache, CDN cache và Service Worker CacheStorage là các lớp khác nhau. Đừng tạo nhiều cache mà không có owner/invalidation story.
Bảng Cache-Control theo loại file#
| Loại file | Ví dụ | Cache-Control gợi ý |
|---|---|---|
| Asset có hash trong tên | /assets/app.BfVZUq22.js | public, max-age=31536000, immutable |
| HTML và trang điều hướng | /, /catalog | public, max-age=0, must-revalidate hoặc no-cache |
| Service worker | /sw.js | no-cache (trình duyệt cần kiểm bản mới) |
| Manifest ứng dụng | /manifest.webmanifest | no-cache hoặc ngắn |
| API riêng của user | /api/me | private, no-store hoặc private có kiểm soát |
Hash trong tên file là điều kiện để dám đặt immutable; thiếu nó thì mọi bản deploy kẹt cache cũ. Cách đặt header trên CDN và Cloudflare ở GĐ17 mục 4; mục 12 bên dưới cho thấy site của repo này đang dùng giá trị mặc định thay vì dòng đầu tiên của bảng.
Tách chunk bằng dynamic import và đo kết quả#
Đã chạy (Vite 8.3): vite build tách search.js (kèm dữ liệu nặng) thành chunk riêng 54,44 kB (gzip 17,23 kB theo Vite), còn bundle ban đầu chỉ 2,24 kB (gzip 1,08 kB). Chunk lazy chỉ được tải ở lần focus đầu. Đánh đổi như ở bài lab: lần gõ đầu chờ tải chunk, nên preload khi hover hoặc focus. Để thấy gói nào chiếm byte, dùng bundle analyzer của bundler bạn dùng (chưa chạy ở đây).
8. Web Worker — CPU nặng ra khỏi main thread#
Dedicated Web Worker chạy JavaScript trong execution context riêng. Nó không thao tác DOM trực tiếp; hai bên trao đổi qua postMessage. Dữ liệu thường được structured-clone, nên gửi cả object graph lớn có thể tốn thời gian/bộ nhớ.
Hợp với search index/ranking lớn, parse CSV/JSON/markdown, transform bytes, encode/decode hoặc tính toán thuần. Không cần Worker cho một map() nhỏ hay fetch thông thường. Worker startup, bundle download, message serialization và lifecycle cũng có chi phí. Đo tổng trải nghiệm thay vì chỉ nhìn main-thread chart.
Ví dụ: search danh mục lớn#
Trang chính tạo Worker và dùng request ID để bỏ response cũ khi user gõ nhanh:
/workers/search.worker.js:
Request ID bỏ stale result nhưng không dừng computation cũ. Với phép tính dài, thiết kế protocol hủy/thay thế theo chunk; worker.terminate() dừng Worker đột ngột và phải tạo mới nếu còn dùng.
Transferable data#
Với ArrayBuffer lớn, chuyển ownership thay vì structured-clone cả bytes:
Transfer giảm copy cho buffer tương thích nhưng data gốc bị detach. Nếu UI vẫn cần bytes, copy có chủ đích hoặc thiết kế owner rõ. SharedArrayBuffer có yêu cầu security/isolation riêng; đừng dùng làm mặc định.
Worker production concerns#
- Tạo Worker URL cùng origin nếu có thể, khai báo CSP
worker-src, version asset và error reporting. - Giới hạn data mỗi message; chỉ gửi fields cần thiết.
- Không gửi function/DOM node; structured clone không giữ semantics của prototype/class thông thường.
- Có fallback/error state; tính năng chính không nên treo nếu Worker load fail.
- Theo dõi queue/message/compute time riêng; Worker không tự giảm tổng CPU hay memory.
Worker trong bundler, SSR guard, Comlink và số đo#
Ví dụ ở đầu mục 8 dùng file tĩnh tự phục vụ (/workers/search.worker.js), không có hash và không qua bundler. Trong dự án có bundler, dùng URL tương đối với import.meta.url để bundler nhận ra Worker, bundle nó và gắn hash:
Với Vite, thêm worker: { format: 'es' } trong cấu hình để Worker dạng module build đúng. Comlink biến postMessage thành lời gọi hàm bất đồng bộ; đổi lại bạn vẫn phải tự xử lý request ID bỏ kết quả cũ (if (id === latest) render(hits)), lỗi và hủy.
Đã chạy (Chrome 154, 50.000 tài liệu, mỗi lần gõ chạy tìm kiếm có bỏ dấu; tìm trên main thread, Worker thuần postMessage và Comlink):
| CPU | Chế độ | Long task | Task dài nhất | Tổng thời gian bị chặn | Sự kiện input dài nhất |
|---|---|---|---|---|---|
| 1x | main | 1 | 53 ms | 53 ms | 56 ms |
| 1x | Worker | 0 | 0 | 0 | 16 ms |
| 1x | Comlink | 0 | 0 | 0 | 16 ms |
| 4x chậm | main | 14 | 209 ms | 2590 ms | 216 ms |
| 4x chậm | Worker | 0 | 0 | 0 | 16 ms |
| 4x chậm | Comlink | 0 | 0 | 0 | 16 ms |
Worker không làm tìm kiếm nhanh hơn: ở 1x, độ trễ từ phím cuối tới kết quả là 45 ms trên main thread so với 74-79 ms với Worker (chi phí message và clone). Cái nó đổi lại là ô nhập luôn phản hồi; ở máy chậm giả lập (4x), main thread bị chặn tổng cộng khoảng 2,6 giây và kết quả đến sau 184 ms, so với 59-70 ms ở hai chế độ Worker. Đó là lý do đo ở CPU throttle, không chỉ ở máy dev. Một lần đo trên một máy, không phải benchmark chuẩn.
SharedArrayBuffer chỉ có khi trang cô lập chéo nguồn. Đã chạy: không có header thì crossOriginIsolated là false và SharedArrayBuffer là undefined; với Cross-Origin-Opener-Policy: same-origin cùng Cross-Origin-Embedder-Policy: require-corp thì crossOriginIsolated là true và SharedArrayBuffer có sẵn. Hệ quả: mọi tài nguyên cross-origin (ảnh, script, iframe) phải gửi Cross-Origin-Resource-Policy hoặc CORS phù hợp, nếu không sẽ bị chặn; kiểm toàn bộ embed trước khi bật. typeof Worker và typeof window đều là undefined trong Node (SSR guard ở trên bắt đúng trường hợp này).
9. Service Worker — network interception, offline và update#
Service Worker là worker event-driven gắn với một origin/path scope. Nó nhận install, activate, fetch, push event và có thể xử lý response; browser có thể dừng rồi khởi chạy lại nó. Nó không có DOM, không sống liên tục và không phải nơi giữ biến như server process.
Service Worker chỉ chạy trong secure context (thường HTTPS; localhost được browser xem như secure khi dev). Async work trong install/activate cần nối vào event.waitUntil() để browser biết tác vụ còn chạy.
Web Worker và Service Worker khác nhau#
| Dedicated Web Worker | Service Worker | |
|---|---|---|
| Bài toán chính | CPU work không chặn main thread | Network/offline/cache/push theo origin/scope |
| Lifecycle | Page tạo Worker | Browser quản lý registration, install, waiting, activate |
| DOM access | Không | Không |
| Request interception | Không mặc định; có thể tự gọi fetch | Có thể xử lý fetch trong scope |
| Giao tiếp | postMessage page ↔ worker | postMessage qua clients/controller khi cần |
| Sống liên tục? | Thường gắn với owner page | Không; browser có thể terminate giữa event |
Service Worker là một loại Web Worker, nhưng không thay cho Dedicated Worker để chạy search CPU-intensive.
Đăng ký#
Không tự gọi skipWaiting()/clients.claim() ở mọi app. Activate version mới giữa lúc tab cũ mở có thể để HTML/runtime cũ chạy cùng cache/API version mới. Thường nên báo “có bản cập nhật, tải lại” hoặc đảm bảo backward compatibility trước khi force takeover.
Cache strategy theo request#
| Loại request | Strategy gợi ý | Điều kiện |
|---|---|---|
| Asset có content hash | Cache-first | Chỉ cache file immutable; giữ asset cũ khi tab/HTML cũ còn dùng |
| Navigation/app shell | Network-first, offline fallback | Ưu tiên dữ liệu live; fallback đủ cho trải nghiệm offline |
| Catalog công khai ít đổi | Stale-while-revalidate hoặc network-first | Chấp nhận stale có chủ đích, UI báo thời điểm cập nhật |
/api/me, payment, private file | Network-only hoặc policy riêng | Không cache response user-specific vô tình |
| POST/PUT/DELETE | Network-only mặc định | Offline write cần IndexedDB outbox, idempotency và conflict UX |
CacheStorage do app quản lý; không tự xử lý freshness theo HTTP policy như bạn có thể mong. Cần version key, cleanup, quota/eviction và account/logout plan.
Mẫu Service Worker: static cache + offline navigation#
Ví dụ này chỉ cache hashed assets và offline page khi navigation thất bại. Tên asset thực tế cần lấy từ build manifest.
Mẫu trên cần adapt vào pipeline thật:
- Precache theo manifest; không hardcode hash mới mỗi release.
- Chỉ xóa cache có prefix/app owner; cùng origin có thể có nhiều ứng dụng.
- Nếu asset cache-first lỗi, cần network fallback và telemetry.
- Giữ asset hash cũ trên CDN đủ lâu để tab cũ không request file đã xóa.
- Không cache API authenticated mặc định; logout cần xóa hoặc rotate cache có dữ liệu user.
- Test lần cài đầu, update waiting, tab cũ, offline, quota, logout/login account khác và clear site data.
Workbox: precache theo manifest, navigation preload, xóa dữ liệu khi logout#
Hardcode PRECACHE = ['/assets/app.abc123.js'] như mẫu trên không sống nổi qua nhiều bản build. Workbox (workbox-build) sinh danh sách file kèm revision từ thư mục build và chèn vào self.__WB_MANIFEST khi dùng injectManifest:
Logout phải gọi navigator.serviceWorker.controller?.postMessage({ type: 'CLEAR_USER_DATA' }) (và xóa outbox IndexedDB như ví dụ ở trên); nếu không, dữ liệu của account trước sống sót qua đăng xuất.
Đã chạy (workbox-build 7.4.1, esbuild, Chrome 154, server tạm): injectManifest sinh dist/sw.js precache 3 file (201 byte), node --check sạch. Trong Chrome: đăng ký SW, tải lại thì controller có; navigationPreload.getState() trả { enabled: true }; gọi /api/catalog hai lần tạo cache storefront-data-v1; sau postMessage({ type: 'CLEAR_USER_DATA' }) cache đó biến mất, còn precache giữ nguyên. Chưa chạy: luồng update hai bản build (waiting rồi SKIP_WAITING), NetworkFirst ở chế độ offline thật, quota/eviction.
Offline mutation là bài toán riêng#
Service Worker một mình không đủ để cho user chỉnh dữ liệu offline. Cần:
- Lưu command vào IndexedDB với
operationId, schema version, user/account scope và thời gian. - UI hiển thị
queued/offline/syncing/failed, cho user xem hoặc xóa thao tác chờ. - Gửi command với idempotency key để server chịu retry/lặp.
- Xử lý conflict khi server đã thay đổi dữ liệu, auth hết hạn hoặc schema không tương thích.
- Đồng bộ khi app mở lại là baseline; Background Sync có thể là enhancement với fallback.
Không phát lại payment/order command mù quáng sau khi mất mạng: server có thể đã xử lý request nhưng browser chưa nhận response.
Ví dụ có đáp án: outbox IndexedDB cho một thao tác offline
Mục đích: minh hoạ năm ý ở danh sách trên bằng thao tác ít rủi ro (thêm sản phẩm vào giỏ). Thao tác payment/order thì không tự động phát lại như thế này nếu server chưa hỗ trợ idempotency key và người dùng chưa xác nhận lại.
Code tham chiếu (chỉ kiểm kiểu bằng tsc 7.0 --strict, chưa chạy trong trình duyệt):
Kết quả mong đợi (suy ra, chưa chạy): tắt mạng, gọi enqueue hai lần thì IndexedDB có hai bản ghi queued; bật mạng và gọi sync thì hai POST đi theo thứ tự, mỗi cái mang Idempotency-Key khác nhau; gửi lại cùng op (ví dụ tab thứ hai chạy sync song song) mang cùng key nên server trả kết quả cũ thay vì tạo bản thứ hai (giả định server hỗ trợ key này, xem GĐ10 mục 3). Lỗi hay gặp: không scope theo userId nên account B đồng bộ thao tác của account A; không xoá outbox khi logout; hai tab cùng chạy sync (cần khoá, ví dụ Web Locks API, hoặc dựa hoàn toàn vào idempotency phía server); coi fetch thất bại là "chưa xử lý" trong khi server có thể đã xử lý (vì vậy cần key).
10. Performance budgets và release process#
Đặt budget theo route/device thay vì một số chung cho mọi trang. Ví dụ khởi điểm cho route catalog mobile:
| Hạng mục | Budget giả định | Cách đo |
|---|---|---|
| Initial JS transfer | ≤ 200 KiB gzip | build analyzer/network cold cache |
| Critical images | ≤ 300 KiB trước LCP | request waterfall |
| Long task | không task > 200 ms trong interaction chính | Performance trace/RUM diagnostic |
| LCP field p75 mobile | ≤ 2.5 s | RUM/CrUX khi có dữ liệu |
| INP field p75 mobile | ≤ 200 ms | RUM |
| CLS field p75 mobile | ≤ 0.1 | RUM |
Đây là budget minh họa; điều chỉnh theo sản phẩm, network và thiết bị. Tổng bytes không thay cho CWV; CWV không thay cho task success/UX research.
Release gate gợi ý#
- Có benchmark trước/sau cùng route, build mode, device và cache state.
- Lighthouse/trace chỉ ra nguồn chậm, không chỉ có tổng score.
- CI so sánh bundle regression với baseline; đặt threshold có owner để giảm false positive.
- RUM alert tách route, mobile/desktop, app version và p75.
- Canary/rollout mới so với control; định nghĩa rollback trigger trước.
- Service Worker update test gồm old tab + new build + old asset eviction.
- Không tắt accessibility hoặc bật cache giả tạo chỉ để score đẹp.
Gắn budget vào CI#
Bundle: size-limit đọc file build và fail pipeline khi vượt ngưỡng.
Đã chạy (size-limit 14.1 với @size-limit/file, sau vite build mẫu ở mục 7): chunk lazy báo 13.46 kB so với giới hạn 10 kB, Package size limit has exceeded by 3.46 kB, mã thoát 1 (đủ để fail CI). Lưu ý con số của size-limit (13,46 kB) khác con số gzip của Vite (17,23 kB) do cách nén và đo khác nhau: chọn một công cụ làm chuẩn cho budget và so sánh cùng một công cụ giữa các bản build.
Lab (Lighthouse CI) kiểm các route chính trên bản build trước khi merge. Lab đo được LCP, CLS và Total Blocking Time (đại diện cho INP), không đo được INP thật:
Code tham chiếu, chưa chạy: @lhci/cli chưa được cài trong lab này (JSON hợp lệ cú pháp). Lighthouse lab dao động giữa các lần chạy: dùng numberOfRuns lẻ, đặt ngưỡng có biên, và để RUM p75 ở mục 3 là chuẩn cuối cùng. Cách dựng pipeline ở GĐ15 mục 8; gắn kiểm tra vào CI chung ở GĐ13 mục 14. Chi phí tải remote của Micro Frontend cần nằm trong cùng budget này: xem GĐ26 mục 7.
11. Lab — storefront responsive, nhanh và offline#
Tình huống#
Trang catalog có 5.000 sản phẩm, filter giá/danh mục, ảnh thumbnail, Service Worker giữ app shell và một catalog response công khai. Người dùng mở lại trang lúc mạng chập chờn; search không được block input.
Phần A — baseline#
-
Chọn route, ghi build ID, device/browser, viewport, network/CPU profile.
Lời giải và cách kiểm tra
Hướng làm: chọn
/catalog; ghi build ID, trình duyệt, viewport, throttle mạng và CPU vào một bảng cấu hình để ai cũng tái lập được. Lỗi hay gặp: đo bản dev (không minify), một lần đo duy nhất, bật extension làm nhiễu. -
Đo cold-cache và warm-cache riêng.
Lời giải và cách kiểm tra
Hướng làm: mỗi cấu hình đo cold cache (DevTools: Network "Disable cache" hoặc cửa sổ ẩn danh mới) và warm cache riêng. Lỗi hay gặp: gộp cold với warm trong một số.
-
Ghi LCP element/waterfall, INP interaction chậm, CLS source, JS/CSS/image transfer.
Lời giải và cách kiểm tra
Hướng làm: Performance panel ghi một lần tải và một lần gõ search để lấy LCP element, interaction chậm, nguồn layout shift; Network lấy transfer JS/CSS/ảnh. Lighthouse CLI nếu repo đã có (chưa chạy):
npx lighthouse http://localhost:3000/catalog --only-categories=performance --output=json --output-path=./baseline.json(mặc định mô phỏng mobile). Kết quả mong đợi: một bảng riêng cho từng cấu hình, bạn tự điền (chưa đo, không có số mẫu):textReady -
Bật RUM p75 theo route/device; không gộp test traffic với production traffic.
Lời giải và cách kiểm tra
Hướng làm: dùng
web-vitalsnhư mục 3, gắnappVersionvà route, tách traffic test khỏi production (ví dụ cờenvở payload hoặc endpoint riêng). p75 tính theo route và thiết bị. Chưa chạy.
Phần B — sửa performance#
-
Tối ưu LCP image discovery/srcset/priority; xác minh không lazy-load hero.
Lời giải và cách kiểm tra
Hướng làm: hero là
<img>có trong HTML ban đầu, không lazy, đặt ưu tiên cao, có kích thước. Code tham chiếu (chưa chạy):textReadyKết quả mong đợi: trong waterfall, request ảnh hero bắt đầu sớm (không đợi JS render), pha "resource load delay" của LCP co lại. Lỗi hay gặp:
loading="lazy"trên hero; ảnh chèn bằng JS sau khi framework hydrate nên trình duyệt phát hiện muộn. -
Reserve image/ad dimensions; xử lý font/layout shift.
Lời giải và cách kiểm tra
Hướng làm: mọi ảnh/iframe/quảng cáo có
width/heighthoặcaspect-ratio; slot quảng cáo cómin-height; font dùngfont-display: swapkèm font fallback hiệu chỉnh (size-adjust) khi chữ nhảy rõ. Kết quả mong đợi: trace không còn layout shift gắn với ảnh sản phẩm; kiểm bằng "Layout shifts" trong Performance panel. Lỗi hay gặp: chèn banner phía trên nội dung đang đọc; skeleton cao khác thẻ thật. -
Chia search code khỏi initial route nếu không cần ngay; tránh request waterfall.
Lời giải và cách kiểm tra
Hướng làm:
import('./search')khi người dùng focus ô tìm kiếm hoặc khi route cần; preload khi hover/focus để không tạo waterfall mới. Kết quả mong đợi: initial JS giảm xấp xỉ kích thước module search (đo bằng bundle analyzer). Lỗi hay gặp: tách xong nhưng tải ở lần gõ đầu nên lần gõ đầu chậm. -
Virtualize results nếu DOM/render cost đo được là nút thắt.
Lời giải và cách kiểm tra
Chỉ làm khi trace cho thấy render danh sách là nút thắt: 5.000 node DOM thường là đáng nghi, nhưng quyết định bằng số đo. Có thể chọn thư viện virtual list đang dùng trong repo hoặc
content-visibility: autovớicontain-intrinsic-sizecho phương án nhẹ; tác dụng cần đo, chưa đo ở đây. -
Nếu query/ranking nặng, chuyển CPU work sang Web Worker; gửi request ID và bỏ stale result.
Lời giải và cách kiểm tra
Sơ đồ.
textReadyHướng làm: dựng index một lần, truyền id kết quả thay vì cả document, chia khối để nhận được tin nhắn mới và tự bỏ phép tính cũ. Phía trang giữ
latestRequestIdnhư mục 8, thêmpostMessage({ type: 'cancel', requestId })khi người dùng xoá ô tìm kiếm. Code tham chiếu (chỉnode --check, chưa chạy trong trình duyệt):typescriptReadyKết quả mong đợi (suy ra): gõ nhanh không làm input giật vì quét nằm ngoài main thread; trong Performance panel, track "Worker" có công việc còn main thread không có long task do search; kết quả cũ không bao giờ ghi đè kết quả mới. Lỗi hay gặp: vòng lặp đồng bộ dài trong worker nên message
cancelkhông bao giờ được xử lý (đó là lý do cầnawait tick()); gửi lại toàn bộdocumentsmỗi lần gõ; không cóerrorhandler nên search chết im lặng. -
Chạy lại trace và ghi số trước/sau, không chỉ screenshot score.
Lời giải và cách kiểm tra
Chạy lại đúng bảng Phần A, cùng thiết bị và cache state; ghi cạnh số cũ. Chưa đo. Sai thường gặp: so sánh cold trước với warm sau.
Phần C — offline an toàn#
-
Precache offline page và hashed app assets.
Lời giải và cách kiểm tra
Hướng làm: chỉ precache file bất biến: tên có hash lấy từ manifest build (ví dụ
workbox-build7.xinjectManifest), không gõ tay. Xem nhánhinstalltrongsw.jsở Khung dùng chung. Lỗi hay gặp: precacheapp.abc123.jsgõ tay không khớp build mới nêncache.addAll404 và install thất bại (SW không bao giờ kích hoạt). -
Chọn catalog strategy, báo thời điểm cập nhật khi trả cached value.
Lời giải và cách kiểm tra
Hướng làm: chỉ
/api/catalogcông khai dùng stale-while-revalidate; UI hiện thời điểm lấy dữ liệu từ headerx-cached-atdosw.jsthêm vào. Header này chỉ có trên bản lấy từ cache: response không có header nghĩa là dữ liệu mới vừa lấy từ mạng.Sơ đồ.
textReadyLỗi hay gặp: cache response có
Cache-Control: private/no-store(hàmisCacheabletrongsw.jschặn); quênevent.waitUntilquanhrefresh/cache.putnên browser dừng SW giữa chừng. -
Giữ
/api/me, payment, private documents và mutation khỏi generic cache rule.Lời giải và cách kiểm tra
Hướng làm: không viết nhánh nào cho
/api/me, thanh toán, file riêng tư và mọi POST/PUT/DELETE:fetchevent bỏ qua nên đi thẳng mạng. Logout xoá cache dữ liệu và outbox (code chưa chạy):typescriptReadyLỗi hay gặp: cache
/api/*bằng một rule chung nên dữ liệu người dùng này lộ sang người dùng khác sau logout. -
Mô phỏng lần cài mới, update waiting, tab cũ mở, offline, logout/login bằng user khác, cache clear/quota.
Lời giải và cách kiểm tra
Hướng làm: bốn thứ cần mô phỏng: lần cài mới, update waiting với tab cũ, offline, logout/login khác user. Trang dùng cập nhật có chủ đích (chưa chạy):
typescriptReadySơ đồ.
textReadyTest matrix (kết quả mong đợi suy ra từ code, chưa chạy):
textReadyPhép kiểm tự động bằng Playwright (chưa chạy;
context.setOfflinecó trong Playwright, kiểm trên bản bạn cài). Lưu ý: chưa xác minhsetOfflinecó chặn cảfetchdo Service Worker gọi hay không, nên test này có thể không mô phỏng đúng offline thật; cách chắc chắn là tắt hẳn server dev hoặc bật Offline trong DevTools rồi quan sát tay:typescriptReadyLỗi hay gặp:
skipWaiting()tronginstallnên HTML cũ chạy với SW mới; xoá hết cache cũ ởactivatenên tab cũ mất asset (sửa: giữ N-1 và CDN giữ file hash cũ); cache/api/mehay mọi/api/*bằng một rule chung nên dữ liệu người dùng này lộ sang người dùng khác sau logout; quênevent.waitUntilquanhcache.put/refreshnên browser dừng SW giữa chừng; precacheapp.abc123.jsgõ tay không khớp build mới nêncache.addAll404 và install thất bại (SW không bao giờ kích hoạt); cache response cóCache-Control: private/no-store. -
Nếu làm offline writes, thêm IndexedDB outbox + idempotency + conflict UI; không dựa Background Sync là đường duy nhất.
Lời giải và cách kiểm tra
Hướng làm: dùng outbox IndexedDB và
Idempotency-Keyở ví dụ "outbox IndexedDB cho một thao tác offline" trong mục 9; Background Sync chỉ là phần tăng cường (chủ yếu có ở trình duyệt nhân Chromium; chưa xác minh phạm vi hiện tại, kiểm bảng tương thích của MDN), nền tảng vẫn là đồng bộ khi mở app và khi có sự kiệnonline. Không tự động phát lại thao tác thanh toán.
Khung và mã dùng chung
Tự làm trước, rồi mới mở. Chưa chạy trong trình duyệt: code dưới đây là code tham chiếu; chỉ kiểm cú pháp (node --check cho hai file JS, tsc --strict cho outbox ở ví dụ mục 9). sw.js đầy đủ và các luồng offline của bài lab này chưa được thử trong trình duyệt nên mọi "kết quả mong đợi" ở đó là suy ra từ tài liệu MDN/web.dev và từ code; phần Service Worker đã chạy thật là bản Workbox ở mục 9 và PWA của repo ở mục 12. Chưa đo Core Web Vitals hay Lighthouse: bài này chỉ nêu ngưỡng chính thức (LCP ≤ 2,5 s, INP ≤ 200 ms, CLS ≤ 0,1 ở p75, tách mobile/desktop), bạn tự điền số đo của mình vào bảng.
sw.js đầy đủ (chỉ node --check; mỗi bước Phần C trích một nhánh của file này):
Tự kiểm Definition of done (không phải checklist của bộ theo dõi). Mỗi dòng cần một bằng chứng bạn có thể chỉ ra: bảng trước/sau (Phần A, B), trace có long task trước và sau khi chuyển sang Worker, test matrix ở trên với kết quả thật bạn chạy, và sơ đồ cập nhật Service Worker (Phần C bước 4) khớp với hành vi bạn quan sát. Dòng "đạt p75" chỉ trả lời được bằng số field của bạn: nếu chưa có dữ liệu, ghi baseline và issue cụ thể thay vì khẳng định đạt.
Đầu ra#
- Performance report có waterfall/trace, RUM baseline, top 3 nguyên nhân và số đo sau sửa.
- Code Worker có message protocol, error state, stale result handling và transfer khi data lớn.
- Service Worker có cache rules theo route, version cleanup, navigation fallback và không cache private response.
- Test matrix cho mobile/desktop, cold/warm, online/offline, update, auth/logout.
- Performance budget và release/rollback threshold có lý do.
Definition of done#
- Giải thích LCP/INP/CLS bằng symptom cụ thể; biết FID không còn là Core Web Vital hiện hành.
- Đạt target p75 cho ba Core Web Vitals hoặc có baseline và issue cụ thể nếu chưa đạt.
- Có ít nhất một long task được xử lý đúng nguồn; không thêm Worker chỉ để “có Worker”.
- App có offline UX đã công bố; private data không sống lại sau logout hoặc lộ sang account khác.
- SW update không khiến tab HTML cũ gọi hashed asset đã bị xóa.
- Có bằng chứng cold-cache, slow network và low-end CPU; không chỉ đo trên laptop dev.
12. Case study — PWA của chính repo này#
Site bạn đang đọc là một PWA, nên đây là ví dụ thật để áp các khái niệm của mục 9 và 10. Các file liên quan: .vitepress/config.ts (khối pwa của withPwa), .vitepress/theme/PwaPrompt.vue (hộp thông báo), public/_worker.js (API ebook trên Cloudflare Pages). Cấu hình đáng chú ý trong config.ts: registerType: 'prompt', precache **/*.{js,css,html,svg,png,ico,woff2} với file tối đa 5 MiB, navigateFallback: '/', navigateFallbackDenylist loại /api/ebooks, cleanupOutdatedCaches: true và experimental.includeAllowlist: true. PwaPrompt.vue dùng useRegisterSW: hiện "Đã lưu xong — giờ đọc được cả khi không có mạng." khi precache xong, và hiện nút "Tải lại" gọi updateServiceWorker(true) khi có bản mới (đúng nguyên tắc ở mục 9: không tự skipWaiting). _worker.js trả API ebook với Cache-Control: private, no-store.
Đã chạy: dựng bản build có sẵn trong .vitepress/dist bằng một server tạm (đặt Cache-Control: no-store cho mọi file trừ sw.js để cô lập tác động của SW), mở bằng Chrome 154 sạch.
| Phép đo | Kết quả quan sát |
|---|---|
| Lần mở đầu (cold) | 174 request, 20.273.835 byte (khoảng 19,3 MiB), khoảng 0,9 s trên localhost |
| Precache sau khi cài | 1 cache workbox-precache-v2-... với 152 entry; hộp "đọc được cả khi không có mạng" hiện |
| Tải lại khi SW đã kiểm soát | 0 request tới server, controller có |
Tắt server, mở trang GĐ27 và /roadmap | cả hai trả 200 từ cache, tiêu đề đúng |
| Tắt server, mở đường dẫn không có trong site | lỗi ERR_CONNECTION_REFUSED, không có trang dự phòng |
Tắt server, mở /api/ebooks/catalog | lỗi kết nối: API bị loại khỏi fallback đúng như thiết kế |
Số trên đo ở localhost nên không có độ trễ mạng; ý nghĩa nằm ở số byte và số request, không ở mili giây.
Ba phát hiện:
-
Asset có hash không được cache dài. Kiểm site đang chạy ngày 2026-10-05 bằng
curl -I: HTML,/sw.js,/manifest.webmanifestvà/assets/app.BfVZUq22.jsđều trảCache-Control: public, max-age=0, must-revalidate. Với người đã có SW, precache che phần lớn hệ quả; với lần mở đầu và trình duyệt không có SW, mỗi lần tải lại phải hỏi lại server. Repo hiện không có filepublic/_headers. Gợi ý (chưa áp dụng trong repo, cú pháp theo tài liệu Cloudflare Pages, chưa deploy thử):textReady -
Precache cả site khoảng 19 MiB ở lần mở đầu là một đánh đổi có chủ đích (đọc toàn bộ tài liệu offline) nhưng tốn dữ liệu di động. Theo thiết kế precache của Workbox, bản deploy sau chỉ tải entry đổi revision; chưa đo trên hai bản build liên tiếp.
-
Điều hướng tới đường dẫn lạ khi offline không có trang dự phòng.
experimental.includeAllowlistlàmNavigationRoutetrongdist/sw.jschỉ áp dụng cho danh sách trang đã biết, nênnavigateFallback: '/'không bắt các đường dẫn khác. Với site tài liệu đó chấp nhận được; với app nên cóoffline.htmlrõ ràng như mẫu ở mục 9.
Chưa chạy: luồng cập nhật với hai bản build (waiting, nút "Tải lại"), quota/eviction, Lighthouse và RUM cho site này.
-
Giải thích vì sao tải lại trang lần hai không gửi request nào, và vì sao mở
/api/ebooks/catalogkhi offline vẫn lỗiĐáp án
SW đã precache toàn bộ HTML/JS/CSS nên các điều hướng và asset đã biết được phục vụ từ CacheStorage mà không chạm mạng.
/api/ebooks/catalognằm trongnavigateFallbackDenylistvà không có route cache nào, nên đi thẳng mạng và lỗi khi offline. Đó là chủ ý: dữ liệu theo phiên người dùng không nên sống trong cache chung. Sai thường gặp: tưởng cứ có SW là mọi URL đều dùng được offline. Xem mục 9. -
Vì sao
max-age=0, must-revalidatetrên file có hash là điểm yếu, và sửa thế nào mà không làm người dùng kẹt bản cũ?Đáp án
Tên file chứa hash nên nội dung không bao giờ đổi, mỗi lần tải lại không cần hỏi server. Đặt
public, max-age=31536000, immutablecho/assets/*; HTML vàsw.jsgiữno-cache/max-age=0để trỏ tới asset mới. Giữ asset hash cũ trên server đủ lâu để tab cũ không gọi file đã xóa. Sai thường gặp: đặtimmutablecho file không có hash hoặc cho cả HTML. Xem bảng ở mục 7. -
Offline, người dùng mở một URL không có trong site và thấy lỗi trình duyệt. Nguyên nhân và hai cách xử lý
Đáp án
includeAllowlistgiới hạnNavigationRoutevào danh sách trang đã biết (quan sát trongdist/sw.js), nên URL lạ không rơi vàonavigateFallback. Cách 1: chấp nhận cho site tài liệu. Cách 2: bỏ allowlist và đổi fallback sangoffline.html, hoặc thêm nhánhcatchtrảoffline.htmlnhư mẫu ở mục 9 để hiển thị thông báo và đường về trang chủ. Sai thường gặp: trả trang/làm fallback cho mọi URL, che giấu 404 thật. Xem mục 9.