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-vitals 6.2, Workbox 7.4 (workbox-build), Comlink 4.4, Vite 8.3, React 19.3, size-limit 14.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ứngCâu hỏi đầu tiên
Trang trống lâuHTML/TTFB chậm, render-blocking CSS/JS hay LCP resource bị phát hiện muộn?
Content hiện nhưng nút lagJS 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ậmCache, service worker, browser cache hay CDN? Cold path có đạt mục tiêu?
Chỉ một phần user chậmThiế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 PaintKhi 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 ShiftMứ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.

textReady
 field p75 xấu ──► tách cohort: route x thiết bị x app version x navigation        │        ├─ cohort nào xấu? ──► tái hiện ở lab: đúng thiết bị/throttle,        │                      cache lạnh, đã đăng nhập        │        └─ trace: LCP element là gì, thời gian rơi vào pha nào?           (TTFB / phát hiện / tải resource / render)              ──► sửa đúng pha ──► đo lại field

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:

typescriptReady
import { onCLS, onINP, onLCP } from 'web-vitals'function sendMetric(metric: { name: string; value: number; id: string }) {  const body = JSON.stringify({    name: metric.name,    value: metric.value,    id: metric.id,    route: location.pathname,    appVersion: document.documentElement.dataset.appVersion,  })  const payload = new Blob([body], { type: 'application/json' })  if (!navigator.sendBeacon('/api/rum/web-vitals', payload)) {    void fetch('/api/rum/web-vitals', {      method: 'POST',      body,      keepalive: true,      headers: { 'Content-Type': 'application/json' },    })  }}onLCP(sendMetric)onINP(sendMetric)onCLS(sendMetric)

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):

typescriptReady
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals/attribution'function partsOf(metric: Metric): Record<string, number | string | undefined> {  const a = (metric as Metric & { attribution: Record<string, unknown> }).attribution  switch (metric.name) {    case 'LCP':      return { target: a.target as string, ttfb: a.timeToFirstByte as number,        loadDelay: a.resourceLoadDelay as number, loadDuration: a.resourceLoadDuration as number,        renderDelay: a.elementRenderDelay as number }    case 'INP':      return { target: a.interactionTarget as string, type: a.interactionType as string,        inputDelay: a.inputDelay as number, processing: a.processingDuration as number,        presentation: a.presentationDelay as number }    case 'CLS':      return { target: a.largestShiftTarget as string, shift: a.largestShiftValue as number }    default:      return {}  }}// gửi { name, value, route, device, appVersion, parts: partsOf(metric) } như sendMetric ở trên

Đã 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.

typescriptReady
const NAMES = new Set(['LCP', 'INP', 'CLS'])const ROUTE = /^\/[A-Za-z0-9/_-]{0,100}$/ // không query, không ký tự lạfunction parse(raw: string) {  let v: unknown  try { v = JSON.parse(raw) } catch { return null }  if (typeof v !== 'object' || v === null) return null  const r = v as Record<string, unknown>  if (!NAMES.has(r.name as string)) return null  if (typeof r.value !== 'number' || !Number.isFinite(r.value) || r.value < 0 || r.value > 600_000) return null  if (typeof r.route !== 'string' || !ROUTE.test(r.route)) return null  if (r.device !== 'mobile' && r.device !== 'desktop') return null  return { name: r.name, value: r.value, route: r.route, device: r.device,    appVersion: String(r.appVersion).slice(0, 40) } // chỉ chép trường đã biết}

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):

typescriptReady
export function p75(values: number[]): number {  const s = [...values].sort((a, b) => a - b)  return s[Math.max(0, Math.ceil(0.75 * s.length) - 1)]}

Đã 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ừ:

  1. HTML/document response bắt đầu muộn (server/TTFB).
  2. Browser phát hiện LCP resource muộn vì nó chỉ xuất hiện trong CSS/JS sau hydration.
  3. Resource tải lâu vì ảnh nặng, network chậm hoặc priority thấp.
  4. 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#

  1. Chọn đúng URL và viewport mobile; clear cache/site data để đo cold load.
  2. Bật network/CPU throttling thực tế; reload và xác định phần tử LCP trong Performance/Lighthouse.
  3. Đọc waterfall: document → stylesheet/script → LCP resource → font → render.
  4. Dùng trace kiểm tra main-thread tasks, style/layout và image decode trước LCP.
  5. 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/height hoặc aspect-ratio cho ả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:

RouteCách có ảnhLCP p75loadDelayloadDuration
Ảnh do JS chèn sau 500 msimg.src gán trong script820 mskhoảng 510 mskhoảng 300 ms
Ảnh trong HTML<img> có sẵn316 mskhoảng 4 mskhoả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:

textReady
<img src="/img/hero-800.avif" width="800" height="450" alt="Bộ sưu tập mới"     fetchpriority="high" decoding="async"><!-- Chỉ khi ảnh không nằm trong HTML ban đầu: --><link rel="preload" as="image" href="/img/hero-800.avif" fetchpriority="high">

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ũ:

typescriptReady
window.addEventListener('pageshow', (event) => {  if (event.persisted) refreshStaleData() // trang vừa được khôi phục từ bfcache})

Đ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ý#

  1. Tìm interaction chậm trong field data/session replay/Performance trace.
  2. Tách thời gian chờ input, event handler, framework rendering và style/layout.
  3. Sửa code synchronous: giảm vòng lặp, tránh serialize/parse data dư, tránh render lại cả cây.
  4. Chia task dài thành chunk để browser xử lý input/paint giữa các phần.
  5. Nếu phép tính CPU độc lập với DOM, chuyển sang Worker sau khi đo overhead message/clone.
  6. 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:

typescriptReady
async function indexInBatches(items: Item[]) {  const results: IndexedItem[] = []  for (let offset = 0; offset < items.length; offset += 100) {    const batch = items.slice(offset, offset + 100)    results.push(...batch.map(indexItem))    await new Promise<void>((resolve) => setTimeout(resolve, 0))  }  return results}

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:

typescriptReady
export function yieldToMain(): Promise<void> {  const scheduler = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler  if (typeof scheduler?.yield === 'function') return scheduler.yield()  return new Promise((resolve) => setTimeout(resolve, 0))}export async function processInSlices<T>(items: readonly T[], work: (item: T) => void, sliceMs = 50) {  let deadline = performance.now() + sliceMs  for (const item of items) {    work(item)    if (performance.now() > deadline) {      await yieldToMain()      deadline = performance.now() + sliceMs    }  }}

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ễ.

tsxReady
const deferredQuery = useDeferredValue(query)const stale = query !== deferredQuery// <Results query={deferredQuery} /> bọc trong memo; aria-busy={stale} để báo trạng thái chờ

Đã 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 document xử 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/height cho <img> và dùng CSS responsive max-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-display có 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):

textReady
@font-face {  font-family: 'Brand Fallback';  src: local('Arial');  size-adjust: 104%;  ascent-override: 92%;  descent-override: 24%;}body { font-family: 'Brand', 'Brand Fallback', sans-serif; }

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-store theo 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 fileVí dụCache-Control gợi ý
Asset có hash trong tên/assets/app.BfVZUq22.jspublic, max-age=31536000, immutable
HTML và trang điều hướng/, /catalogpublic, max-age=0, must-revalidate hoặc no-cache
Service worker/sw.jsno-cache (trình duyệt cần kiểm bản mới)
Manifest ứng dụng/manifest.webmanifestno-cache hoặc ngắn
API riêng của user/api/meprivate, 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ả#

typescriptReady
document.querySelector('#q').addEventListener('focus', async () => {  const { search } = await import('./search.js') // tải khi người dùng thật sự cần  console.log(search('a'))}, { once: true })

Đã 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:

typescriptReady
const searchWorker = new Worker('/workers/search.worker.js', { type: 'module' })let latestRequestId = 0// Khởi tạo index một lần khi dữ liệu catalog đã tải; không clone toàn bộ list mỗi keystroke.searchWorker.postMessage({ type: 'init', documents: initialDocuments })export function searchDocuments(query: string) {  const requestId = ++latestRequestId  searchWorker.postMessage({ type: 'search', requestId, query })}searchWorker.addEventListener('message', (event) => {  if (event.data.type === 'ready') return  if (event.data.type !== 'results') return  if (event.data.requestId !== latestRequestId) return  renderSearchResults(event.data.results)})searchWorker.addEventListener('error', (event) => {  reportClientError('search-worker-failed', event.message)  showSearchFailure()})

/workers/search.worker.js:

typescriptReady
let documents = []function scoreMatch(text, query) {  if (!query) return 0  const index = text.toLocaleLowerCase().indexOf(query)  return index === -1 ? 0 : 100 - Math.min(index, 99)}self.addEventListener('message', (event) => {  const { type, requestId, query } = event.data  if (type === 'init') {    documents = event.data.documents    self.postMessage({ type: 'ready' })    return  }  if (type !== 'search') return  const normalized = query.trim().toLocaleLowerCase()  const results = documents    .map((document) => ({      document,      score: scoreMatch(document.searchText, normalized),    }))    .filter((result) => result.score > 0)    .sort((a, b) => b.score - a.score)    .slice(0, 50)  self.postMessage({ type: 'results', requestId, results })})

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:

typescriptReady
const bytes = await file.arrayBuffer()worker.postMessage({ type: 'parse', bytes }, [bytes])// Sau transfer, bytes phía trang bị detach và không còn dùng được.

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.

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:

typescriptReady
// main.tsimport * as Comlink from 'comlink'import type { SearchApi } from './search.worker'export function createSearch() {  if (typeof Worker === 'undefined') return null // SSR hoặc môi trường không có Worker  const worker = new Worker(new URL('./search.worker.ts', import.meta.url), { type: 'module' })  return Comlink.wrap<SearchApi>(worker)}
typescriptReady
// search.worker.tsimport * as Comlink from 'comlink'let docs: Doc[] = []const api = {  init(documents: Doc[]) { docs = documents; return docs.length },  search(query: string): Hit[] { return searchAll(docs, query) },}export type SearchApi = typeof apiComlink.expose(api)

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):

CPUChế độLong taskTask dài nhấtTổng thời gian bị chặnSự kiện input dài nhất
1xmain153 ms53 ms56 ms
1xWorker00016 ms
1xComlink00016 ms
4x chậmmain14209 ms2590 ms216 ms
4x chậmWorker00016 ms
4x chậmComlink00016 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 WorkerService Worker
Bài toán chínhCPU work không chặn main threadNetwork/offline/cache/push theo origin/scope
LifecyclePage tạo WorkerBrowser quản lý registration, install, waiting, activate
DOM accessKhôngKhông
Request interceptionKhông mặc định; có thể tự gọi fetchCó thể xử lý fetch trong scope
Giao tiếppostMessage page ↔ workerpostMessage qua clients/controller khi cần
Sống liên tục?Thường gắn với owner pageKhô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ý#

typescriptReady
if ('serviceWorker' in navigator && window.isSecureContext) {  const registration = await navigator.serviceWorker.register('/sw.js', {    scope: '/',  })  registration.addEventListener('updatefound', () => {    const installing = registration.installing    installing?.addEventListener('statechange', () => {      if (installing.state === 'installed' && navigator.serviceWorker.controller) {        showUpdateAvailablePrompt()      }    })  })}

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 requestStrategy gợi ýĐiều kiện
Asset có content hashCache-firstChỉ cache file immutable; giữ asset cũ khi tab/HTML cũ còn dùng
Navigation/app shellNetwork-first, offline fallbackƯu tiên dữ liệu live; fallback đủ cho trải nghiệm offline
Catalog công khai ít đổiStale-while-revalidate hoặc network-firstChấp nhận stale có chủ đích, UI báo thời điểm cập nhật
/api/me, payment, private fileNetwork-only hoặc policy riêngKhông cache response user-specific vô tình
POST/PUT/DELETENetwork-only mặc địnhOffline 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.

typescriptReady
const CACHE_PREFIX = 'storefront-shell-'const CACHE_NAME = `${CACHE_PREFIX}v3`const PRECACHE = ['/offline.html', '/assets/app.abc123.js', '/assets/app.abc123.css']self.addEventListener('install', (event) => {  event.waitUntil(    caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE)),  )})self.addEventListener('activate', (event) => {  event.waitUntil((async () => {    const names = await caches.keys()    await Promise.all(names      .filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)      .map((name) => caches.delete(name)))  })())})self.addEventListener('fetch', (event) => {  const request = event.request  const url = new URL(request.url)  if (request.method !== 'GET' || url.origin !== self.location.origin) return  if (request.mode === 'navigate') {    event.respondWith((async () => {      try {        return await fetch(request)      } catch {        const cache = await caches.open(CACHE_NAME)        return (await cache.match('/offline.html')) ?? new Response(          'Đang offline. Hãy thử lại khi có mạng.',          { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },        )      }    })())    return  }  if (url.pathname.startsWith('/assets/')) {    event.respondWith((async () => {      const cache = await caches.open(CACHE_NAME)      const cached = await cache.match(request)      if (cached) return cached      const response = await fetch(request)      if (response.ok) {        event.waitUntil(cache.put(request, response.clone()))      }      return response    })())  }})

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:

typescriptReady
// sw-src.js (Workbox 7.4)import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'import { registerRoute, NavigationRoute } from 'workbox-routing'import { NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'import { enable as enableNavigationPreload } from 'workbox-navigation-preload'const DATA_CACHE = 'storefront-data-v1'precacheAndRoute(self.__WB_MANIFEST)   // workbox-build chèn tên file + revision lúc buildcleanupOutdatedCaches()// Trình duyệt gửi request điều hướng song song với việc khởi động SW.enableNavigationPreload()registerRoute(new NavigationRoute(new NetworkFirst({ cacheName: 'storefront-pages', networkTimeoutSeconds: 3 })))// Chỉ danh mục công khai. /api/me, thanh toán, file riêng tư không có route nào => đi thẳng mạng.registerRoute(({ url, request }) => request.method === 'GET' && url.pathname === '/api/catalog',  new StaleWhileRevalidate({ cacheName: DATA_CACHE }))self.addEventListener('message', (event) => {  if (event.data?.type === 'SKIP_WAITING') self.skipWaiting()  if (event.data?.type === 'CLEAR_USER_DATA') {    event.waitUntil(Promise.all([caches.delete(DATA_CACHE), caches.delete('storefront-pages')]))  }})
typescriptReady
// build-sw.mjsimport { build } from 'esbuild'import { injectManifest } from 'workbox-build'await build({ entryPoints: ['sw-src.js'], bundle: true, format: 'iife', minify: true, outfile: 'sw-bundled.js' })await injectManifest({ swSrc: 'sw-bundled.js', swDest: 'dist/sw.js', globDirectory: 'dist',  globPatterns: ['**/*.{html,js,css}'], globIgnores: ['sw.js'] })

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:

  1. Lưu command vào IndexedDB với operationId, schema version, user/account scope và thời gian.
  2. UI hiển thị queued/offline/syncing/failed, cho user xem hoặc xóa thao tác chờ.
  3. Gửi command với idempotency key để server chịu retry/lặp.
  4. Xử lý conflict khi server đã thay đổi dữ liệu, auth hết hạn hoặc schema không tương thích.
  5. Đồ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.

textReady
 UI ──enqueue──► IndexedDB 'ops' (operationId, userId, status=queued)                        │  khi app mở / sự kiện 'online' / sau enqueue                        ▼                  sync(userId): theo thứ tự createdAt                        │ POST + Idempotency-Key = operationId        ┌───────────────┼───────────────────────────┐     2xx: xoá op   mạng lỗi/5xx/401/408/429:   4xx khác (409, 422):                   queued, dừng vòng, thử sau  failed -> UI xem/xoá/sửa

Code tham chiếu (chỉ kiểm kiểu bằng tsc 7.0 --strict, chưa chạy trong trình duyệt):

typescriptReady
export type Op = {  operationId: string  userId: string  schemaVersion: 1  type: 'cart.add'  payload: { sku: string; qty: number }  createdAt: number  status: 'queued' | 'syncing' | 'failed'  error?: string}const open = () => new Promise<IDBDatabase>((resolve, reject) => {  const req = indexedDB.open('storefront-outbox', 1)  req.onupgradeneeded = () => req.result.createObjectStore('ops', { keyPath: 'operationId' })  req.onsuccess = () => resolve(req.result)  req.onerror = () => reject(req.error)})const done = (tx: IDBTransaction) => new Promise<void>((resolve, reject) => {  tx.oncomplete = () => resolve()  tx.onerror = () => reject(tx.error)})export async function enqueue(userId: string, type: Op['type'], payload: Op['payload']) {  const db = await open()  const op: Op = { operationId: crypto.randomUUID(), userId, schemaVersion: 1, type, payload, createdAt: Date.now(), status: 'queued' }  const tx = db.transaction('ops', 'readwrite')  tx.objectStore('ops').put(op)  await done(tx)  return op}async function all(db: IDBDatabase, userId: string): Promise<Op[]> {  const req = db.transaction('ops').objectStore('ops').getAll()  const ops = await new Promise<Op[]>((resolve, reject) => {    req.onsuccess = () => resolve(req.result as Op[]); req.onerror = () => reject(req.error)  })  return ops.filter((o) => o.userId === userId).sort((a, b) => a.createdAt - b.createdAt)}async function patch(db: IDBDatabase, op: Op | null, id: string, change: Partial<Op> | 'delete') {  const tx = db.transaction('ops', 'readwrite')  if (change === 'delete') tx.objectStore('ops').delete(id)  else tx.objectStore('ops').put({ ...op!, ...change })  await done(tx)}/** Gọi khi app mở, khi có sự kiện 'online', và sau mỗi enqueue. Dừng ở lỗi mạng/5xx/401/408/429. */export async function sync(userId: string) {  const db = await open()  for (const op of await all(db, userId)) {    if (op.status === 'failed') continue    await patch(db, op, op.operationId, { status: 'syncing' })    let res: Response    try {      res = await fetch('/api/cart/items', {        method: 'POST',        headers: { 'Content-Type': 'application/json', 'Idempotency-Key': op.operationId },        body: JSON.stringify(op.payload),      })    } catch {      await patch(db, op, op.operationId, { status: 'queued' }); return // mất mạng: thử lại sau    }    if (res.ok) { await patch(db, null, op.operationId, 'delete'); continue }    if (res.status === 401 || res.status === 408 || res.status === 429 || res.status >= 500) {      await patch(db, op, op.operationId, { status: 'queued' }); return // chờ đăng nhập lại / server ổn / hết giới hạn    }    await patch(db, op, op.operationId, { status: 'failed', error: `HTTP ${res.status}` }) // 409/422: UI hỏi người dùng  }}export async function clearUser(userId: string) {  const db = await open()  for (const op of await all(db, userId)) await patch(db, null, op.operationId, 'delete')}

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ụcBudget giả địnhCách đo
Initial JS transfer≤ 200 KiB gzipbuild analyzer/network cold cache
Critical images≤ 300 KiB trước LCPrequest waterfall
Long taskkhông task > 200 ms trong interaction chínhPerformance trace/RUM diagnostic
LCP field p75 mobile≤ 2.5 sRUM/CrUX khi có dữ liệu
INP field p75 mobile≤ 200 msRUM
CLS field p75 mobile≤ 0.1RUM

Đâ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.

jsonReady
[  { "name": "initial JS (route catalog)", "path": "dist/assets/index-*.js", "limit": "2 kB", "gzip": true },  { "name": "search chunk (lazy)", "path": "dist/assets/search-*.js", "limit": "10 kB", "gzip": true }]

Đã 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:

jsonReady
{  "ci": {    "collect": { "url": ["http://localhost:4173/catalog"], "numberOfRuns": 3,                 "startServerCommand": "npm run preview" },    "assert": { "assertions": {      "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],      "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],      "total-blocking-time": ["warn", { "maxNumericValue": 200 }]    } }  }}

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#

  1. 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.

  2. Đ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ố.

  3. 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
     route     device  cache  LCP element   LCP  INP   CLS  JS KiB  img KiB /catalog  mobile  cold   (từ trace)    __   __    __   __      __ /catalog  mobile  warm   ...
  4. 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-vitals như mục 3, gắn appVersion và 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#

  1. 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):

    textReady
    <img src="/img/hero-800.avif" width="800" height="450" alt="Bộ sưu tập mới"     srcset="/img/hero-400.avif 400w, /img/hero-800.avif 800w, /img/hero-1600.avif 1600w"     sizes="(max-width: 800px) 100vw, 800px" fetchpriority="high" decoding="async">

    Kế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.

  2. 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/height hoặc aspect-ratio; slot quảng cáo có min-height; font dùng font-display: swap kè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.

  3. 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.

  4. 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: auto với contain-intrinsic-size cho phương án nhẹ; tác dụng cần đo, chưa đo ở đây.

  5. 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ơ đồ.

    textReady
      main thread                          search.worker  ───────────                          ─────────────  init(documents) ───────────────────► dựng haystack 1 lần ─► ready  gõ "a"  ─ search #1 ───────────────► quét từng khối 500, nhường vòng lặp  gõ "ab" ─ search #2 ───────────────► nhận #2 giữa hai khối ─► #1 tự thoát                                       quét xong #2  render(results #2) ◄───────────────── results {requestId: 2}  (nếu results #1 vẫn đến muộn: requestId != latest ─► bỏ)

    Hướ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ữ latestRequestId như mục 8, thêm postMessage({ 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):

    typescriptReady
    let documents = []let latestId = 0const tick = () => new Promise((resolve) => setTimeout(resolve, 0))self.addEventListener('message', async (event) => {  const msg = event.data  if (msg.type === 'init') {    documents = msg.documents.map((d) => ({ ...d, haystack: d.searchText.toLocaleLowerCase() }))    self.postMessage({ type: 'ready' })    return  }  if (msg.type === 'cancel') { latestId = Math.max(latestId, msg.requestId) + 1; return } // +1: huỷ cả search đang chạy với id này  if (msg.type !== 'search') return  latestId = msg.requestId  const q = msg.query.trim().toLocaleLowerCase()  const hits = []  for (let i = 0; i < documents.length; i += 500) {    // nhường vòng lặp sự kiện để nhận message mới (cancel / search kế tiếp)    await tick()    if (msg.requestId !== latestId) return    for (const d of documents.slice(i, i + 500)) {      const at = q ? d.haystack.indexOf(q) : -1      if (at !== -1) hits.push({ id: d.id, score: 100 - Math.min(at, 99) })    }  }  hits.sort((a, b) => b.score - a.score)  self.postMessage({ type: 'results', requestId: msg.requestId, results: hits.slice(0, 50) })})

    Kế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 cancel không bao giờ được xử lý (đó là lý do cần await tick()); gửi lại toàn bộ documents mỗi lần gõ; không có error handler nên search chết im lặng.

  6. 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#

  1. 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-build 7.x injectManifest), không gõ tay. Xem nhánh install trong sw.js ở Khung dùng chung. Lỗi hay gặp: precache app.abc123.js gõ tay không khớp build mới nên cache.addAll 404 và install thất bại (SW không bao giờ kích hoạt).

  2. 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/catalog công khai dùng stale-while-revalidate; UI hiện thời điểm lấy dữ liệu từ header x-cached-at do sw.js thê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ơ đồ.

    textReady
     fetch event (cùng origin, GET)   ├─ navigate ──────────► network; lỗi ─► /offline.html (đã precache)   ├─ /assets/* hash ────► cache ─ hit ─► trả; miss ─► mạng, lưu cache   ├─ /api/catalog ──────► stale-while-revalidate (data cache, x-cached-at)   └─ còn lại (/api/me, thanh toán, file riêng tư, POST/PUT/DELETE)                         ► không có nhánh nào: đi thẳng mạng

    Lỗi hay gặp: cache response có Cache-Control: private/no-store (hàm isCacheable trong sw.js chặn); quên event.waitUntil quanh refresh/cache.put nên browser dừng SW giữa chừng.

  3. 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: fetch event bỏ qua nên đi thẳng mạng. Logout xoá cache dữ liệu và outbox (code chưa chạy):

    typescriptReady
    export async function onLogout() {  navigator.serviceWorker.controller?.postMessage({ type: 'CLEAR_USER_DATA' })  await clearUser(currentUserId) // outbox, mục 9}

    Lỗ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.

  4. 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):

    typescriptReady
    const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' })reg.addEventListener('updatefound', () => {  const next = reg.installing  next?.addEventListener('statechange', () => {    if (next.state === 'installed' && navigator.serviceWorker.controller) {      showUpdatePrompt(() => next.postMessage({ type: 'SKIP_WAITING' })) // bấm "Tải lại" mới gọi    }  })})navigator.serviceWorker.addEventListener('controllerchange', () => location.reload())

    Sơ đồ.

    textReady
     v4 vừa deploy ─► install (precache v4) ─► waiting (tab cũ do v3 giữ)   page: updatefound + installed + có controller ─► hiện "Có bản cập nhật"   người dùng bấm ─► postMessage SKIP_WAITING ─► v4 activate        ─► controllerchange ─► page reload ─► HTML mới + asset hash mới   activate chỉ xoá cache version < N-1: tab cũ chưa reload vẫn tìm asset v3   (trong cache v3 hoặc trên CDN, miễn CDN còn giữ file hash cũ)

    Test matrix (kết quả mong đợi suy ra từ code, chưa chạy):

    textReady
     kịch bản                              mong đợi cài lần đầu, online                   shell + offline.html vào cache offline, mở route đã từng ghé         navigate fail ─► /offline.html;                                       asset hash lấy từ cache offline, /api/catalog đã có cache     trả bản cũ, UI hiện "cập nhật lúc" offline, /api/catalog chưa có cache   lỗi mạng ─► UI báo không có dữ                                       liệu (cần xử lý trong UI) /api/me khi offline                   lỗi mạng ngay; không có bản cũ deploy v4, tab v3 còn mở              v4 waiting; tab v3 vẫn tải asset                                       v3; hiện nút cập nhật logout rồi login account khác         data cache và outbox cũ bị xoá Clear site data / hết quota           SW đăng ký lại; không lỗi cứng

    Phép kiểm tự động bằng Playwright (chưa chạy; context.setOffline có trong Playwright, kiểm trên bản bạn cài). Lưu ý: chưa xác minh setOffline có chặn cả fetch do 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:

    typescriptReady
    test('offline hiển thị trang dự phòng', async ({ page, context }) => {  await page.goto('/catalog')  await page.evaluate(() => navigator.serviceWorker.ready)  await context.setOffline(true)  await page.reload()  await expect(page.getByText('offline', { exact: false })).toBeVisible()})

    Lỗi hay gặp: skipWaiting() trong install nên HTML cũ chạy với SW mới; xoá hết cache cũ ở activate nên tab cũ mất asset (sửa: giữ N-1 và CDN giữ file hash cũ); cache /api/me hay 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ên event.waitUntil quanh cache.put/refresh nên browser dừng SW giữa chừng; precache app.abc123.js gõ tay không khớp build mới nên cache.addAll 404 và install thất bại (SW không bao giờ kích hoạt); cache response có Cache-Control: private/no-store.

  5. 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ện online. 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):

typescriptReady
const SHELL_PREFIX = 'storefront-shell-'const SHELL_VERSION = 4const SHELL_CACHE = `${SHELL_PREFIX}v${SHELL_VERSION}`const DATA_CACHE = 'storefront-data-v1'const PRECACHE = ['/offline.html', '/assets/app.abc123.js', '/assets/app.abc123.css']self.addEventListener('install', (event) => {  event.waitUntil(caches.open(SHELL_CACHE).then((c) => c.addAll(PRECACHE)))  // không gọi skipWaiting() ở đây: chờ người dùng đồng ý (xem message bên dưới)})self.addEventListener('activate', (event) => {  event.waitUntil((async () => {    for (const name of await caches.keys()) {      if (!name.startsWith(SHELL_PREFIX)) continue      const version = Number(name.slice(SHELL_PREFIX.length + 1))      // giữ bản liền trước cho tab cũ còn mở      if (version < SHELL_VERSION - 1) await caches.delete(name)    }  })())})self.addEventListener('message', (event) => {  if (event.data?.type === 'SKIP_WAITING') self.skipWaiting()  if (event.data?.type === 'CLEAR_USER_DATA') event.waitUntil(caches.delete(DATA_CACHE))})const isCacheable = (res) =>  res.status === 200 && !/no-store|private/i.test(res.headers.get('Cache-Control') ?? '')async function staleWhileRevalidate(event) {  const cache = await caches.open(DATA_CACHE)  const cached = await cache.match(event.request)  const refresh = fetch(event.request).then(async (res) => {    if (isCacheable(res)) {      const headers = new Headers(res.headers)      headers.set('x-cached-at', String(Date.now()))      await cache.put(event.request, new Response(await res.clone().blob(), { status: res.status, headers }))    }    return res  })  if (cached) { event.waitUntil(refresh.catch(() => {})); return cached }  return refresh}self.addEventListener('fetch', (event) => {  const { request } = event  const url = new URL(request.url)  if (request.method !== 'GET' || url.origin !== self.location.origin) return  if (request.mode === 'navigate') {    event.respondWith(fetch(request).catch(async () =>      (await caches.match('/offline.html')) ??      new Response('Đang offline.', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } })))    return  }  if (url.pathname.startsWith('/assets/')) {    event.respondWith(caches.match(request).then((hit) => hit ?? fetch(request).then((res) => {      if (res.status === 200) {   // không cache 206 (cache.put ném lỗi với partial response)        const copy = res.clone()        event.waitUntil(caches.open(SHELL_CACHE).then((c) => c.put(request, copy)))      }      return res    })))    return  }  if (url.pathname === '/api/catalog') { event.respondWith(staleWhileRevalidate(event)); return }  // /api/me, thanh toán, file riêng tư: không có nhánh nào ở đây => đi thẳng mạng})

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 đoKế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ài1 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át0 request tới server, controller có
Tắt server, mở trang GĐ27 và /roadmapcả hai trả 200 từ cache, tiêu đề đúng
Tắt server, mở đường dẫn không có trong sitelỗi ERR_CONNECTION_REFUSED, không có trang dự phòng
Tắt server, mở /api/ebooks/cataloglỗ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:

  1. 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.webmanifest và /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ó file public/_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
    /assets/*  Cache-Control: public, max-age=31536000, immutable
  2. 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.

  3. Điều hướng tới đường dẫn lạ khi offline không có trang dự phòng. experimental.includeAllowlist làm NavigationRoute trong dist/sw.js chỉ áp dụng cho danh sách trang đã biết, nên navigateFallback: '/' 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.html rõ 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/catalog khi 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/catalog nằm trong navigateFallbackDenylist và 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-revalidate trê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, immutable cho /assets/*; HTML và sw.js giữ 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: đặt immutable cho 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

    includeAllowlist giới hạn NavigationRoute vào danh sách trang đã biết (quan sát trong dist/sw.js), nên URL lạ không rơi vào navigateFallback. Cách 1: chấp nhận cho site tài liệu. Cách 2: bỏ allowlist và đổi fallback sang offline.html, hoặc thêm nhánh catch trả offline.html như 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.


Tài liệu tham khảo#