Tối ưu Core Web Vitals (LCP, INP, CLS) cho website Next.js: đo, tìm nguyên nhân và sửa
Cách đo LCP, INP, CLS bằng dữ liệu người dùng thật (CrUX, web-vitals attribution), chia LCP thành 4 phần để sửa đúng chỗ, giữ chỗ chống CLS, giải phóng main thread cho INP và dùng Long Animation Frames để tìm script gây chậm trong Next.js.
Tình huống quen thuộc: Lighthouse trên máy bạn báo Performance 96, nhưng Search Console lại đánh dấu nhóm trang sản phẩm trên mobile là "Kém". PageSpeed Insights hiển thị dữ liệu người dùng thật với LCP 3,9s, INP 380ms, CLS 0,21 (số minh hoạ, nhưng độ lệch kiểu này rất hay gặp). Bạn không sai khi đo, bạn chỉ đang đo một thứ khác. Lighthouse chạy một lần trong điều kiện giả lập. Còn Google đánh giá trang bằng phân vị 75 của người dùng Chrome thật trong 28 ngày.
Bài này đi theo đúng thứ tự mình xử lý một site Next.js bị "đỏ" Core Web Vitals. Đầu tiên là thu dữ liệu field có attribution để biết vì sao chậm chứ không chỉ chậm bao nhiêu. Sau đó sửa LCP theo từng phần nhỏ, sửa CLS bằng cách giữ chỗ, sửa INP bằng cách trả main thread về cho trình duyệt, và cuối cùng là chặn regression trong CI. Phần tối ưu render của React (re-render, memo, React Compiler, virtualization) mình tách sang bài Tối ưu hiệu năng React. Bài này tập trung vào chỉ số tải trang và tương tác của toàn trang.
TL;DR:
- Ngưỡng "tốt" ở phân vị 75: LCP ≤ 2,5s, INP ≤ 200ms, CLS ≤ 0,1. INP thay FID từ tháng 3/2024.
- Lab data (Lighthouse) dùng để debug và chặn regression. Field data (CrUX, RUM) mới là thứ Google dùng để chấm.
- LCP = TTFB + resource load delay + resource load duration + element render delay. Đo từng phần rồi mới sửa.
- CLS gần như luôn là "thứ gì đó xuất hiện muộn mà không được giữ chỗ": ảnh, quảng cáo, banner, font.
- INP chậm là do main thread bận: hydration, handler nặng, script bên thứ ba. Chia nhỏ task, yield,
useTransition, và tải script đúng strategy.
Ba chỉ số, ngưỡng và cách Google chấm điểm
| Chỉ số | Đo cái gì | Tốt | Cần cải thiện | Kém |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Thời điểm phần tử nội dung lớn nhất trong viewport được vẽ xong | ≤ 2,5s | 2,5s đến 4s | > 4s |
| INP (Interaction to Next Paint) | Độ trễ từ lúc tương tác (click, tap, phím) đến lần paint kế tiếp | ≤ 200ms | 200ms đến 500ms | > 500ms |
| CLS (Cumulative Layout Shift) | Tổng mức dịch chuyển layout không mong muốn (cụm lớn nhất) | ≤ 0,1 | 0,1 đến 0,25 | > 0,25 |
Mấy chi tiết quyết định cách bạn tối ưu:
- Phân vị 75, tách theo thiết bị. Một URL đạt khi 75% lượt truy cập có chỉ số nằm trong ngưỡng tốt. Mobile và desktop được chấm riêng. 25% người dùng chậm nhất (máy yếu, mạng 4G chập chờn) không làm bạn trượt, nhưng nhóm 50 đến 75 thì có.
- INP thay FID từ ngày 12/3/2024. FID chỉ đo input delay của lần tương tác đầu tiên. INP đo toàn bộ độ trễ (input delay + thời gian chạy handler + thời gian đến khi frame tiếp theo được vẽ) của mọi tương tác trong suốt vòng đời trang, rồi lấy gần như tương tác tệ nhất (cứ 50 tương tác thì bỏ qua một giá trị cao nhất). Nhiều site từng "xanh" với FID đã chuyển sang "đỏ" với INP.
- CLS tính theo session window. Các layout shift cách nhau dưới 1s được gộp thành một cụm, mỗi cụm tối đa 5s, và CLS là cụm có tổng lớn nhất. Shift xảy ra trong 500ms sau một tương tác của người dùng (click mở accordion chẳng hạn) không bị tính.
- LCP dừng cập nhật khi người dùng tương tác. Phần tử ứng viên là ảnh, poster video, ảnh nền CSS (
url()) hoặc khối text lớn nhất trong viewport.
Field data và lab data: đo ở đâu, tin cái nào
| Nguồn | Loại | Có gì | Dùng để |
|---|---|---|---|
| CrUX (Chrome UX Report) | Field | p75 của người dùng Chrome thật, cửa sổ 28 ngày, theo origin và URL đủ traffic | Biết Google đang thấy gì |
| PageSpeed Insights | Field (CrUX) + lab (Lighthouse) | Hai khối riêng biệt trên cùng trang | Kiểm tra nhanh một URL |
| Search Console, báo cáo Core Web Vitals | Field (CrUX) | Nhóm URL tương tự theo trạng thái | Thấy pattern trên toàn site, xác thực sau khi sửa |
Thư viện web-vitals (bản attribution) |
Field (RUM của bạn) | Giá trị + nguyên nhân (phần tử, script, phase) cho từng lượt | Tìm nguyên nhân thật, đo ngay sau deploy |
| Vercel Speed Insights | Field (RUM) | p75 theo route, theo thiết bị | Có sẵn nếu deploy trên Vercel |
| Lighthouse / DevTools Performance | Lab | Trace chi tiết, giả lập CPU và mạng | Debug có tái hiện, chặn regression trong CI |
Hai hệ quả thực tế. Thứ nhất, CrUX cần tới 28 ngày để phản ánh bản sửa của bạn. Nếu chỉ nhìn PSI, bạn sẽ mất một tháng mới biết mình sửa đúng hay sai, nên cần RUM riêng. Thứ hai, Lighthouse ở chế độ navigation không đo được INP vì không có người click. Nó dùng Total Blocking Time làm chỉ số thay thế. TBT thấp không đảm bảo INP tốt, vì INP phụ thuộc vào việc người dùng click lúc nào và vào đâu.
Panel Performance của Chrome DevTools hiện có màn hình live metrics. Nó hiển thị LCP (kèm phần tử), CLS và từng tương tác INP ngay khi bạn thao tác trên trang, và có thể so sánh với dữ liệu CrUX của URL. Đây là chỗ mình bắt đầu khi tái hiện bug.
Thu field data có attribution trong Next.js
Next.js có hook useReportWebVitals từ next/web-vitals, đủ dùng nếu bạn chỉ cần giá trị. Mình dùng thẳng bản attribution của thư viện web-vitals (v5) vì nó trả về thêm nguyên nhân: phần tử LCP, các phần của LCP, phần tử gây shift, script dài nhất trong tương tác INP.
npm install web-vitals
// app/_components/web-vitals-reporter.tsx
'use client'
import { useEffect } from 'react'
import { onCLS, onINP, onLCP } from 'web-vitals/attribution'
type VitalEvent = {
name: 'LCP' | 'INP' | 'CLS'
value: number
rating: 'good' | 'needs-improvement' | 'poor'
id: string
navigationType: string
route: string
connection?: string
debug: Record<string, string | number | undefined>
}
const SAMPLE_RATE = 0.2 // gửi 20% lượt xem để tiết kiệm chi phí lưu trữ
function send(event: VitalEvent) {
const body = JSON.stringify(event)
const blob = new Blob([body], { type: 'application/json' })
// sendBeacon vẫn gửi được khi tab đang đóng; fetch keepalive là fallback
if (!navigator.sendBeacon?.('/api/vitals', blob)) {
void fetch('/api/vitals', { method: 'POST', body, keepalive: true, headers: { 'Content-Type': 'application/json' } })
}
}
function base() {
const connection = (navigator as Navigator & { connection?: { effectiveType?: string } }).connection
// chỉ gửi pathname: query string có thể chứa token hoặc email
return { route: location.pathname, connection: connection?.effectiveType }
}
export function WebVitalsReporter() {
useEffect(() => {
if (Math.random() > SAMPLE_RATE) return
onLCP((metric) => {
const a = metric.attribution
send({
...base(),
name: 'LCP',
value: metric.value,
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
debug: {
target: a.target,
url: a.url,
ttfb: a.timeToFirstByte,
loadDelay: a.resourceLoadDelay,
loadDuration: a.resourceLoadDuration,
renderDelay: a.elementRenderDelay,
},
})
})
onINP((metric) => {
const a = metric.attribution
send({
...base(),
name: 'INP',
value: metric.value,
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
debug: {
target: a.interactionTarget,
type: a.interactionType,
loadState: a.loadState,
inputDelay: a.inputDelay,
processing: a.processingDuration,
presentation: a.presentationDelay,
script: a.longestScript?.entry.sourceURL,
scriptInvoker: a.longestScript?.entry.invoker,
scriptSubpart: a.longestScript?.subpart,
},
})
})
onCLS((metric) => {
const a = metric.attribution
send({
...base(),
name: 'CLS',
value: metric.value,
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
debug: { target: a.largestShiftTarget, time: a.largestShiftTime, loadState: a.loadState },
})
})
}, [])
return null
}
Gắn vào root layout một lần (component trả về null nên không ảnh hưởng UI):
// app/layout.tsx
import type { ReactNode } from 'react'
import { WebVitalsReporter } from './_components/web-vitals-reporter'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="vi">
<body>
<WebVitalsReporter />
{children}
</body>
</html>
)
}
Endpoint nhận dữ liệu là public, nên phải validate và giới hạn kích thước. Đừng tin bất cứ thứ gì client gửi lên:
// app/api/vitals/route.ts
const NAMES = new Set(['LCP', 'INP', 'CLS'])
const MAX_BODY_LENGTH = 4096
export async function POST(request: Request) {
const raw = await request.text()
if (raw.length > MAX_BODY_LENGTH) return new Response(null, { status: 413 })
let event: Record<string, unknown>
try {
event = JSON.parse(raw)
} catch {
return new Response(null, { status: 400 })
}
if (!NAMES.has(String(event.name)) || typeof event.value !== 'number' || !Number.isFinite(event.value)) {
return new Response(null, { status: 400 })
}
const userAgent = request.headers.get('user-agent') ?? ''
const device = /Mobi|Android/i.test(userAgent) ? 'mobile' : 'desktop'
// Ghi structured log; pipeline log (hoặc một bảng Postgres) lo phần tổng hợp
console.log(JSON.stringify({ type: 'web_vital', device, ...event, receivedAt: new Date().toISOString() }))
return new Response(null, { status: 204 })
}
Khi đã có dữ liệu trong Postgres, tính p75 theo route giống cách CrUX làm:
SELECT route,
name,
percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75,
count(*) AS samples
FROM web_vitals
WHERE device = 'mobile'
AND received_at > now() - interval '7 days'
GROUP BY route, name
HAVING count(*) >= 200
ORDER BY name, p75 DESC;
HAVING count(*) >= 200 để loại các route ít mẫu, nơi p75 dao động vô nghĩa. Nếu deploy trên Vercel, Speed Insights cho bạn phần tổng hợp này mà không phải tự lưu. Cài @vercel/speed-insights rồi render <SpeedInsights /> từ @vercel/speed-insights/next trong root layout. Nhưng nó không thay được phần attribution ở trên khi bạn cần biết vì sao chậm.
Cũng đừng quên rằng RUM chỉ nhận được metric từ trình duyệt hỗ trợ API tương ứng. Một số chỉ số không có trên mọi trình duyệt, nên dữ liệu của bạn thiên về Chromium, giống CrUX. Bảng hỗ trợ cụ thể có trong README của web-vitals.
LCP: chia thành 4 phần rồi sửa đúng phần
Đừng sửa LCP bằng cách thử ngẫu nhiên. Attribution ở trên cho bạn 4 con số cộng lại bằng LCP:
| Phần | Định nghĩa | Hướng dẫn của web.dev |
|---|---|---|
| TTFB | Từ lúc bắt đầu điều hướng đến byte đầu tiên của HTML | khoảng 40% |
| Resource load delay | Từ TTFB đến lúc trình duyệt bắt đầu tải ảnh LCP | dưới 10% |
| Resource load duration | Thời gian tải chính file ảnh | khoảng 40% |
| Element render delay | Từ lúc ảnh tải xong đến lúc được vẽ | dưới 10% |
Nếu LCP là khối text thì không có phần tải resource: chỉ còn TTFB và render delay (thường do web font hoặc JS chặn render).
Muốn xem nhanh phần tử LCP của một trang, mở Console và chạy:
new PerformanceObserver((list) => {
const last = list.getEntries().at(-1)
console.log('LCP', Math.round(last.startTime), 'ms', last.element, last.url)
}).observe({ type: 'largest-contentful-paint', buffered: true })
Load delay cao: ảnh LCP được phát hiện muộn hoặc bị lazy-load
Đây là nhóm lỗi phổ biến nhất trên Next.js. next/image mặc định loading="lazy". Với ảnh hero, lazy nghĩa là trình duyệt chờ layout xong mới quyết định tải, nên mất vài trăm mili giây vô ích.
// app/(shop)/_components/sale-hero.tsx
import Image from 'next/image'
import saleHero from './sale-hero.jpg' // static import: Next.js tự biết width/height
export function SaleHero() {
return (
<section className="relative">
<Image
src={saleHero}
alt="Giảm 30% toàn bộ giày chạy bộ đến hết 30/11"
loading="eager"
fetchPriority="high"
sizes="(max-width: 768px) 100vw, 1200px"
className="h-auto w-full"
/>
<h1 className="absolute bottom-8 left-8 text-4xl font-bold text-white">Black Friday</h1>
</section>
)
}
Giải thích từng prop:
loading="eager"bỏ lazy-load. Ảnh nằm trong HTML ban đầu, nên preload scanner của trình duyệt thấy nó ngay khi parse.fetchPriority="high"đưa request ảnh lên trước các ảnh và script khác. Mặc định ảnh bắt đầu ở priority thấp cho đến khi trình duyệt biết nó nằm trong viewport.sizesquyết địnhsrcset. Thiếusizes, Next.js chỉ tạo srcset kiểu 1x/2x tính từwidthgốc của ảnh, nên với ảnh gốc 2400px thì điện thoại cũng nhận một bản rất lớn, và resource load duration phình ra. Cósizes, Next.js tạo srcset đầy đủ theo chiều rộng (640w, 750w, 828w, 1080w, 1200w...), và trình duyệt chọn theo chiều rộng hiển thị nhân DPR. Điện thoại 390px với DPR 3 cần khoảng 1170px nên lấy bản 1200w, thay vì bản lớn nhất.- Từ Next.js 16, prop
prioritybị deprecate, thay bằngpreload.preloadchèn thẻ<link rel="preload">vào<head>. Docs của Next.js khuyên trong đa số trường hợp nên dùngloading="eager"hoặcfetchPriority="high", không dùng chung vớipreload. Mình chỉ dùngpreloadkhi ảnh nằm trong phần HTML được stream muộn. Ở Next.js 15 trở về trước,prioritylàm cả ba việc (preload, priority cao, eager).
Ảnh LCP là ảnh nền CSS (background-image) thì tệ hơn: trình duyệt chỉ phát hiện nó sau khi tải và áp dụng CSS. Cách tốt nhất là đổi sang <Image fill> với object-cover. Nếu không đổi được, preload bằng API của React 19, chạy được cả trong Server Component:
// app/(shop)/_components/campaign-banner.tsx
import { preload } from 'react-dom'
export function CampaignBanner() {
preload('/images/campaign-bg-1200.avif', { as: 'image', fetchPriority: 'high' })
return (
<section className="h-[420px] bg-[url('/images/campaign-bg-1200.avif')] bg-cover bg-center">
<h2 className="p-8 text-3xl font-bold">Bộ sưu tập mùa đông</h2>
</section>
)
}
Chỉ preload đúng một ảnh LCP cho mỗi trang. Preload hay fetchPriority="high" cho cả 12 ảnh sản phẩm trong lưới sẽ làm chúng tranh băng thông với nhau. Kết quả là ảnh LCP về chậm hơn lúc chưa làm gì.
Render delay cao: ảnh về rồi nhưng chưa được vẽ
Những nguyên nhân mình hay gặp:
- Hero render ở client. Data hero được fetch trong
useEffect, nên phần tử LCP chỉ xuất hiện sau khi JS tải, hydrate và fetch xong. Chuyển sang Server Component để HTML chứa sẵn ảnh. - Animation fade-in từ
opacity: 0. Thư viện animation setopacity: 0lúc đầu và chỉ hiện sau hydration. LCP được tính khi phần tử thật sự hiển thị. Đừng animate phần tử LCP lúc tải trang. - Script A/B test kiểu anti-flicker ẩn toàn bộ trang cho đến khi script tải xong.
- Text LCP chờ web font với
font-display: block. Dùngnext/font(mặc địnhdisplay: 'swap') như ở phần CLS bên dưới.
TTFB cao: server render chậm cho mỗi request
Nếu TTFB chiếm 60% đến 70% LCP, mọi tối ưu ảnh ở trên chỉ là phần nhỏ. Với trang sản phẩm, render động mỗi request (query DB, gọi API giá) là nguyên nhân thường gặp. Hướng sửa là cache HTML ở mức route:
// app/products/[slug]/page.tsx
import { Suspense } from 'react'
import Image from 'next/image'
import { notFound } from 'next/navigation'
import { getProductBySlug, getTopProductSlugs } from '@/lib/products'
import { Reviews, ReviewsSkeleton } from './reviews'
export const revalidate = 3600 // HTML được cache và làm mới tối đa mỗi giờ
export async function generateStaticParams() {
const slugs = await getTopProductSlugs(500) // build sẵn 500 trang bán chạy nhất
return slugs.map((slug) => ({ slug }))
}
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const product = await getProductBySlug(slug)
if (!product) notFound()
return (
<main>
<Image
src={product.imageUrl}
alt={product.name}
width={800}
height={800}
loading="eager"
fetchPriority="high"
sizes="(max-width: 768px) 100vw, 50vw"
/>
<h1>{product.name}</h1>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
</main>
)
}
Ba điểm cần để ý. Phần tử LCP (ảnh sản phẩm) nằm ngoài Suspense, nên nó có trong phần HTML đầu tiên. Phần review chậm được stream sau và không kéo TTFB của cả trang. Và ReviewsSkeleton phải cao xấp xỉ nội dung thật, nếu không bạn đổi được LCP nhưng lại mất CLS.
Nếu project bật cacheComponents của Next.js 16, cách cache chuyển sang directive 'use cache' và cacheLife. Mình viết riêng phần này trong bài App Router và Server Components, còn header cache ở CDN thì có trong bài HTTP caching. Ngoài ra, mọi request đều đi qua middleware (Next.js 16 đổi tên thành proxy), nên một lệnh gọi API chậm trong đó cộng thẳng vào TTFB của mọi trang.
CLS: giữ chỗ cho mọi thứ xuất hiện muộn
Gần như mọi bug CLS mình từng sửa đều có chung một dạng: một phần tử được chèn hoặc đổi kích thước sau khi phần còn lại đã được vẽ, và không có chỗ giữ sẵn cho nó.
Ảnh, embed và quảng cáo
// app/blog/[slug]/_components/media.tsx
import Image from 'next/image'
export function ArticleMedia({ youtubeId }: { youtubeId: string }) {
return (
<>
{/* fill: phần tử cha phải có kích thước, ở đây là aspect-ratio */}
<div className="relative aspect-[16/9] w-full">
<Image src="/images/cover.jpg" alt="Sơ đồ kiến trúc thanh toán" fill sizes="(max-width: 768px) 100vw, 720px" className="object-cover" />
</div>
{/* iframe không biết trước chiều cao: giữ tỉ lệ 16:9 */}
<div className="aspect-video w-full">
<iframe
className="size-full"
src={`https://www.youtube-nocookie.com/embed/${youtubeId}`}
title="Demo tính năng hoá đơn điện tử"
loading="lazy"
allow="accelerometer; encrypted-media; picture-in-picture"
allowFullScreen
/>
</div>
{/* slot quảng cáo: giữ chiều cao của kích thước phổ biến nhất */}
<div className="min-h-[250px] w-full" data-ad-slot="article-inline" />
</>
)
}
next/image bắt buộc width và height (hoặc fill), nên trình duyệt biết tỉ lệ khung hình trước khi ảnh tải. Với fill, trách nhiệm chuyển sang phần tử cha. Một div cha không có chiều cao sẽ cao 0 rồi nhảy lên khi ảnh về. Với quảng cáo có nhiều kích thước, giữ chỗ cho kích thước hay gặp nhất. Khi không có quảng cáo thì để khoảng trống đó, đừng collapse về 0, vì collapse cũng là một lần shift.
Banner, thông báo và nội dung chỉ có ở client
Một pattern rất hay gây CLS trong Next.js là banner khuyến mãi hoặc cookie consent hiển thị dựa trên localStorage trong useEffect. Server render không có banner, client hydrate xong mới chèn banner lên đầu trang, và cả trang bị đẩy xuống. Có ba cách sửa, xếp theo thứ tự mình ưu tiên:
- Quyết định ở server bằng cookie (
cookies()trong Server Component), để HTML đầu tiên đã có hoặc không có banner. - Hiển thị dạng overlay (
position: fixedở đáy màn hình). Phần tử fixed không đẩy nội dung khác nên không tạo layout shift. - Giữ chỗ cố định cho banner rồi mới điền nội dung vào.
Animation cũng vậy: animate transform (translate, scale) không tính vào CLS, còn animate top, margin hay height thì có.
Font: fallback có cùng kích thước
Khi web font tải xong và thay font fallback, chiều rộng chữ thay đổi. Đoạn văn reflow, xuống dòng khác đi, và đẩy mọi thứ bên dưới. next/font tự host font (không có request ra Google lúc runtime) và tạo một font fallback được chỉnh size-adjust cùng các metric ascent/descent, để chữ fallback chiếm gần đúng diện tích chữ thật:
// app/fonts.ts
import { Be_Vietnam_Pro } from 'next/font/google'
import localFont from 'next/font/local'
export const beVietnamPro = Be_Vietnam_Pro({
subsets: ['latin', 'vietnamese'],
weight: ['400', '600', '700'],
display: 'swap',
variable: '--font-sans',
// adjustFontFallback mặc định là true với next/font/google
})
export const brandDisplay = localFont({
src: './fonts/brand-display-variable.woff2',
display: 'swap',
variable: '--font-display',
adjustFontFallback: 'Arial', // mặc định của next/font/local; đặt 'Times New Roman' cho font serif
})
Áp vào <html className={${beVietnamPro.variable} ${brandDisplay.variable}}> rồi dùng biến CSS trong stylesheet. Đừng thêm thẻ <link> tới Google Fonts song song với next/font: bạn sẽ tải font hai lần và mất phần fallback đã được tinh chỉnh.
Debug CLS
Attribution largestShiftTarget cho bạn selector của phần tử bị dịch chuyển nhiều nhất. Lưu ý đó thường là nạn nhân chứ không phải thủ phạm, nên hãy nhìn phần tử ngay phía trên nó. Trong DevTools, bật Rendering, chọn "Layout Shift Regions" để thấy vùng shift được tô màu khi tải trang. Panel Performance cũng có track Layout shifts, hiển thị từng cụm shift trên timeline.
INP: trả main thread về cho trình duyệt
INP gồm ba phần, và attribution cho bạn cả ba:
- Input delay: tương tác phải chờ vì main thread đang bận việc khác (hydration, script quảng cáo, timer).
loadStatecho biết tương tác xảy ra lúc trang đang tải hay đã tải xong. - Processing duration: thời gian chạy event handler, bao gồm cả render React đồng bộ do handler kích hoạt.
- Presentation delay: từ khi handler chạy xong đến frame kế tiếp, tức style, layout, paint. DOM càng lớn thì phần này càng dài.
Chia nhỏ task dài và yield
Main thread chỉ vẽ được frame mới khi task hiện tại kết thúc. Task trên 50ms được coi là long task. Nguyên tắc: cập nhật phần UI mà người dùng chờ thấy trước, rồi nhường main thread để trình duyệt paint, sau đó mới làm phần không gấp.
// lib/yield-to-main.ts
type SchedulerLike = { yield?: () => Promise<void> }
export function yieldToMain(): Promise<void> {
const scheduler = (globalThis as { scheduler?: SchedulerLike }).scheduler
if (typeof scheduler?.yield === 'function') {
// phần việc tiếp theo được ưu tiên chạy trước các task khác đang chờ
return scheduler.yield()
}
// fallback: xếp vào cuối hàng đợi task
return new Promise((resolve) => setTimeout(resolve, 0))
}
// Xử lý mảng lớn theo lô, yield mỗi khi dùng hết ngân sách thời gian
export async function processInChunks<T>(items: readonly T[], work: (item: T) => void, budgetMs = 40) {
let deadline = performance.now() + budgetMs
for (const item of items) {
work(item)
if (performance.now() >= deadline) {
await yieldToMain()
deadline = performance.now() + budgetMs
}
}
}
scheduler.yield() đã có trên Chrome và Edge (từ bản 129) nhưng chưa phải Baseline, nên luôn cần fallback. Điểm khác biệt quan trọng: sau scheduler.yield(), phần code tiếp theo được ưu tiên chạy trước các task khác đang xếp hàng. Còn với setTimeout, nó xếp xuống cuối hàng đợi, và script bên thứ ba có thể chen vào giữa.
Áp vào một handler "Thêm vào giỏ" thực tế:
// app/products/[slug]/add-to-cart-button.tsx
'use client'
import { useState } from 'react'
import { yieldToMain } from '@/lib/yield-to-main'
import { addToCart } from '@/lib/cart'
import { trackEvent } from '@/lib/analytics'
export function AddToCartButton({ productId }: { productId: string }) {
const [status, setStatus] = useState<'idle' | 'adding' | 'added'>('idle')
async function handleClick() {
setStatus('adding') // 1. phản hồi thị giác trước
await yieldToMain() // 2. React đã commit, trình duyệt được paint ngay
trackEvent('add_to_cart', { productId }) // 3. việc không gấp chạy sau paint
await addToCart(productId)
setStatus('added')
}
return (
<button type="button" onClick={handleClick} disabled={status === 'adding'}>
{status === 'adding' ? 'Đang thêm...' : status === 'added' ? 'Đã thêm' : 'Thêm vào giỏ'}
</button>
)
}
INP chỉ đo đến lần paint kế tiếp sau tương tác. Nếu bước 1 được vẽ trong vòng vài chục mili giây, phần việc sau await không tính vào INP của cú click này. Nhưng nếu nó là một task dài, nó vẫn có thể chặn tương tác tiếp theo. Vì vậy việc nặng vẫn nên chia lô bằng processInChunks.
useTransition cho cập nhật nặng do người dùng gõ
Với React, cách tương đương là đánh dấu phần cập nhật nặng là transition. React render phần khẩn cấp (ô input) trước, và render transition theo kiểu có thể bị ngắt, yield giữa các component:
'use client'
import { useState, useTransition } from 'react'
import { ProductGrid } from './product-grid'
import type { Product } from '@/lib/products'
export function ProductSearch({ products }: { products: Product[] }) {
const [query, setQuery] = useState('')
const [filter, setFilter] = useState('')
const [isPending, startTransition] = useTransition()
return (
<>
<input
value={query}
onChange={(e) => {
setQuery(e.target.value) // khẩn cấp: ô input phải cập nhật ngay
startTransition(() => setFilter(e.target.value)) // nặng: lọc và render lưới
}}
placeholder="Tìm sản phẩm"
/>
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<ProductGrid products={products} filter={filter} />
</div>
</>
)
}
Giới hạn cần biết: React yield giữa các component, không yield bên trong một component. Một component tự nó render mất 300ms vẫn chặn main thread 300ms dù nằm trong transition. Lúc đó bạn cần giảm chi phí render (memo, virtualization, React Compiler). Phần đó nằm trong bài Tối ưu hiệu năng React.
Hydration và lượng JavaScript
Khi loadState của các tương tác INP kém chủ yếu là loading hoặc dom-interactive, người dùng đang click trong lúc trang còn hydrate. Main thread bận chạy JS của bạn nên input delay rất cao. Hướng sửa là giảm JS phải hydrate:
- Giữ component là Server Component khi có thể, đẩy
'use client'xuống lá (nút, form) thay vì đặt ở đầu layout. - Widget nặng nằm dưới màn hình đầu tiên (bản đồ cửa hàng, editor, chart) dùng
next/dynamicđể tách chunk và chỉ tải khi cần. - Bọc các vùng tương tác độc lập bằng
Suspense. React hydrate từng boundary và ưu tiên boundary mà người dùng vừa tương tác vào (selective hydration).
Script bên thứ ba với next/script
// app/_components/third-party-scripts.tsx (render trong root layout)
import Script from 'next/script'
import { GoogleTagManager } from '@next/third-parties/google'
export function ThirdPartyScripts() {
return (
<>
{/* Component chính thức của Next.js: GTM được tải sau hydration */}
<GoogleTagManager gtmId={process.env.NEXT_PUBLIC_GTM_ID!} />
{/* Chat widget: không ai cần nó trong 5 giây đầu */}
<Script src="https://chat.example-vendor.com/widget.js" strategy="lazyOnload" />
</>
)
}
| Strategy | Khi nào tải | Dùng cho |
|---|---|---|
beforeInteractive |
Chèn vào HTML ban đầu, chạy trước hydration | Chỉ thứ bắt buộc chạy trước mọi thứ (cookie consent theo luật, bot detection). Phải đặt trong root layout |
afterInteractive (mặc định) |
Sau khi trang đã hydrate một phần | Analytics, tag manager |
lazyOnload |
Lúc trình duyệt rảnh, sau sự kiện load |
Chat widget, social embed, survey |
worker |
Trong web worker qua Partytown | Thử nghiệm, không hỗ trợ App Router |
Với chat widget, mình còn đi xa hơn: render một nút "giả" giống hệt, và chỉ tải script thật khi người dùng hover hoặc click vào nút đó (pattern facade). Script chat thường nặng, và người dùng mở chat thường chỉ là thiểu số.
Tag manager là nguồn INP kém lớn nhất mình từng gặp, vì marketing có thể thêm tag mà không qua code review. Dữ liệu longestScript.entry.sourceURL trong RUM là bằng chứng để mang đi thảo luận với team marketing.
Long Animation Frames: tìm đúng script làm chậm tương tác
Long Tasks API cũ chỉ cho bạn biết "có một task dài 180ms", không nói script nào. Long Animation Frames API (LoAF, có trên Chrome từ bản 123) đo theo frame. Mỗi frame chậm đi kèm danh sách script đã chạy trong frame đó: hàm nào, file nào, được gọi từ đâu, mất bao lâu, và bao nhiêu thời gian bị ép tính layout.
// lib/observe-long-frames.ts
type ScriptTiming = {
invoker: string // ví dụ "BUTTON#checkout.onclick" hoặc "TimerHandler:setTimeout"
invokerType: string // "event-listener" | "user-callback" | "classic-script" | ...
sourceURL: string
sourceFunctionName: string
duration: number
forcedStyleAndLayoutDuration: number
}
type LongAnimationFrame = PerformanceEntry & {
blockingDuration: number
renderStart: number
styleAndLayoutStart: number
scripts: ScriptTiming[]
}
export type LongFrameReport = {
duration: number
blockingDuration: number
topScripts: Array<Pick<ScriptTiming, 'invoker' | 'sourceURL' | 'sourceFunctionName' | 'duration' | 'forcedStyleAndLayoutDuration'>>
}
export function observeLongFrames(report: (data: LongFrameReport) => void): () => void {
if (!PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) return () => {}
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as LongAnimationFrame[]) {
if (entry.blockingDuration < 100) continue // chỉ quan tâm frame chặn đáng kể
const topScripts = [...entry.scripts]
.sort((a, b) => b.duration - a.duration)
.slice(0, 3)
.map(({ invoker, sourceURL, sourceFunctionName, duration, forcedStyleAndLayoutDuration }) => ({
invoker,
sourceURL,
sourceFunctionName,
duration: Math.round(duration),
forcedStyleAndLayoutDuration: Math.round(forcedStyleAndLayoutDuration),
}))
report({ duration: Math.round(entry.duration), blockingDuration: Math.round(entry.blockingDuration), topScripts })
}
})
observer.observe({ type: 'long-animation-frame', buffered: true })
return () => observer.disconnect()
}
Kết quả gửi về sẽ trông như sau (minh hoạ):
{
"duration": 312,
"blockingDuration": 262,
"topScripts": [
{ "invoker": "BUTTON#apply-filter.onclick", "sourceURL": "https://shop.acme.vn/_next/static/chunks/app/products/page-3f1c.js", "sourceFunctionName": "handleApplyFilter", "duration": 188, "forcedStyleAndLayoutDuration": 41 },
{ "invoker": "TimerHandler:setInterval", "sourceURL": "https://tags.vendor-cdn.com/pixel.js", "sourceFunctionName": "", "duration": 64, "forcedStyleAndLayoutDuration": 0 }
]
}
Đọc kết quả này: handler của bạn tốn 188ms, trong đó 41ms là forced layout. Nghĩa là code đọc offsetHeight hoặc getBoundingClientRect() ngay sau khi ghi style, buộc trình duyệt tính layout đồng bộ. Script pixel bên thứ ba chen thêm 64ms. Hai việc cần làm rất rõ ràng.
Trong thực tế, bạn không cần tự gắn observer này cho INP, vì web-vitals/attribution đã gom các LoAF giao với tương tác INP vào longAnimationFrameEntries và tóm tắt script dài nhất trong longestScript. Observer riêng hữu ích khi bạn muốn bắt các frame chậm ngoài tương tác INP, ví dụ jank khi cuộn hoặc khi trang đang tải.
Bảng tổng hợp: chỉ số, nguyên nhân, cách sửa
| Chỉ số | Triệu chứng trong attribution | Nguyên nhân thường gặp | Cách sửa |
|---|---|---|---|
| LCP | ttfb cao |
Render động mỗi request, DB chậm, middleware gọi API, không có CDN | revalidate / 'use cache', static params, CDN cache, stream phần chậm bằng Suspense |
| LCP | loadDelay cao |
Ảnh lazy-load, ảnh nền CSS, ảnh do JS chèn | loading="eager" + fetchPriority="high", preload, đổi sang <Image> |
| LCP | loadDuration cao |
Ảnh quá lớn, thiếu sizes, định dạng cũ |
sizes đúng, AVIF/WebP qua next/image, giảm quality |
| LCP | renderDelay cao |
Hero render ở client, fade-in animation, anti-flicker script, font block | Server Component, bỏ animation phần tử LCP, next/font |
| CLS | largestShiftTarget là nội dung dưới ảnh/embed |
Ảnh, iframe, ad không có kích thước | width/height, aspect-ratio, min-height cho slot |
| CLS | Shift lúc loadState là complete |
Banner chèn sau hydration, skeleton sai chiều cao | Quyết định ở server bằng cookie, overlay fixed, skeleton đúng kích thước |
| CLS | Shift khi font tải xong | Fallback khác metric | next/font với adjustFontFallback |
| INP | inputDelay cao, loadState: loading |
Hydration nặng, script bên thứ ba lúc tải | Ít client component hơn, next/dynamic, lazyOnload, facade |
| INP | processing cao |
Handler nặng, render React đồng bộ lớn | yieldToMain, useTransition, giảm chi phí render |
| INP | presentation cao |
DOM khổng lồ, layout phức tạp, forced layout | Virtualization, content-visibility: auto, tránh đọc layout sau khi ghi style |
Chặn regression trong CI
Sửa xong mà không có rào chắn thì vài sprint sau chỉ số sẽ tụt lại. Mình dùng Lighthouse CI làm hàng rào lab. Nó không thay được RUM, nhưng bắt được những lỗi rõ ràng như ai đó bỏ fetchPriority hay thêm một script chặn render.
{
"ci": {
"collect": {
"startServerCommand": "npm run start",
"url": ["http://localhost:3000/", "http://localhost:3000/products/giay-chay-bo-x1"],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["warn", { "maxNumericValue": 200 }]
}
},
"upload": { "target": "temporary-public-storage" }
}
}
npm run build
npx @lhci/cli autorun --config=./lighthouserc.json
numberOfRuns: 3 để giảm nhiễu, vì một lần chạy Lighthouse dao động khá nhiều trên runner CI dùng chung. Với dữ liệu nhạy cảm, đừng upload lên temporary-public-storage (report public); hãy dùng LHCI server riêng hoặc lưu artifact của CI. Cách tích hợp vào pipeline có trong bài CI/CD với GitHub Actions.
Sau khi deploy bản sửa, theo dõi p75 trong RUM của bạn trong vài ngày. Khi số đã ổn, vào báo cáo Core Web Vitals trong Search Console bấm Validate fix (Xác thực bản sửa lỗi) và chờ CrUX cập nhật theo cửa sổ 28 ngày.
Những lỗi thường gặp
1. Lazy-load ảnh LCP.
// Sai: next/image mặc định lazy, ảnh hero chờ layout mới được tải
<Image src={hero} alt="Khuyến mãi" />
// Đúng
<Image src={hero} alt="Khuyến mãi" loading="eager" fetchPriority="high" sizes="100vw" />
2. Đánh dấu ưu tiên cho mọi ảnh. fetchPriority="high" (hoặc priority ở Next.js 15) trên cả 12 thẻ sản phẩm làm mất ý nghĩa của ưu tiên. Chỉ một ảnh LCP cho mỗi layout. Nếu ảnh LCP khác nhau giữa mobile và desktop, chọn theo dữ liệu target trong RUM.
3. Skeleton không cùng chiều cao với nội dung thật.
// Sai: skeleton 40px, nội dung thật 320px => cả trang nhảy khi stream xong
<Suspense fallback={<div className="h-10 animate-pulse bg-gray-100" />}>
// Đúng: giữ đúng chỗ của 3 review
<Suspense fallback={<div className="h-80 animate-pulse rounded-lg bg-gray-100" />}>
4. Debounce input để "sửa" INP. Debounce giảm số lần lọc, nhưng lần render nặng sau 300ms vẫn chặn main thread. Phím tiếp theo người dùng gõ đúng lúc đó sẽ có input delay lớn. Hãy giảm chi phí render hoặc dùng transition, debounce chỉ là phần phụ.
5. Tối ưu điểm Lighthouse thay vì p75 field. Điểm 100 trên MacBook với mạng văn phòng không nói gì về người dùng Android tầm trung trên 4G. Chỉ coi lab là công cụ debug.
6. Gửi metric trong beforeunload hoặc unload. Trên mobile các sự kiện này không đáng tin và còn làm trang không vào được bfcache. web-vitals đã gửi ở visibilitychange, chỉ cần dùng sendBeacon như ví dụ.
7. Đọc layout ngay sau khi ghi style trong handler.
// Sai: mỗi vòng lặp buộc trình duyệt tính layout đồng bộ (forced reflow)
for (const row of rows) {
row.style.height = `${row.scrollHeight}px`
}
// Đúng: đọc hết trước, ghi hết sau
const heights = rows.map((row) => row.scrollHeight)
rows.forEach((row, i) => {
row.style.height = `${heights[i]}px`
})
Checklist
- Gắn
web-vitals/attribution(LCP, INP, CLS) gửi về endpoint có validate, chỉ gửipathname, có sampling. - Có truy vấn p75 theo route và thiết bị, ít nhất cho mobile.
- Ảnh LCP của từng template (home, listing, detail) có
loading="eager"+fetchPriority="high"vàsizesđúng; không cònpriorityrải rác ở các ảnh khác. - Trang có TTFB cao được cache (
revalidatehoặc'use cache') và phần chậm được stream sauSuspense. - Mọi
fillimage, iframe, slot quảng cáo đều cóaspect-ratiohoặcmin-height; skeleton cao bằng nội dung thật. - Font dùng
next/font, không còn<link>tới Google Fonts. - Handler nặng phản hồi UI trước rồi
yieldToMain(); cập nhật render nặng nằm trongstartTransition. - Script bên thứ ba đã được rà lại strategy; chat widget dùng
lazyOnloadhoặc facade. - Lighthouse CI chạy trên PR với ngưỡng LCP, CLS, TBT.