HARRY-33 — GĐ27 — Frontend Performance: Core Web Vitals, Web Workers, Service Workers & Offline UX
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.
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.
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:
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.
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.
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:
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.
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.
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.
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:
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:
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:
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.
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ý
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 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.
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.
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.
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.
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.
- Đo cold-cache và warm-cache riêng.
- Ghi LCP element/waterfall, INP interaction chậm, CLS source, JS/CSS/image transfer.
- Bật RUM p75 theo route/device; không gộp test traffic với production traffic.
Phần B — sửa performance
- Tối ưu LCP image discovery/srcset/priority; xác minh không lazy-load hero.
- Reserve image/ad dimensions; xử lý font/layout shift.
- Chia search code khỏi initial route nếu không cần ngay; tránh request waterfall.
- Virtualize results nếu DOM/render cost đo được là nút thắt.
- Nếu query/ranking nặng, chuyển CPU work sang Web Worker; gửi request ID và bỏ stale result.
- Chạy lại trace và ghi số trước/sau, không chỉ screenshot score.
Phần C — offline an toàn
- Precache offline page và hashed app assets.
- Chọn catalog strategy, báo thời điểm cập nhật khi trả cached value.
- Giữ
/api/me, payment, private documents và mutation khỏi generic cache rule. - 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.
- Nếu làm offline writes, thêm IndexedDB outbox + idempotency + conflict UI; không dựa Background Sync là đường duy 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.
