BlogBlog · performance

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.

Part of Deep Dive series
July 21, 2025
30 min read
By Leo Pham
performancecore-web-vitalsnextjsseofrontend
Share this post:

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.
  • sizes quyết định srcset. Thiếu sizes, Next.js chỉ tạo srcset kiểu 1x/2x tính từ width gố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 priority bị deprecate, thay bằng preload. preload chè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ùng loading="eager" hoặc fetchPriority="high", không dùng chung với preload. Mình chỉ dùng preload khi ảnh nằm trong phần HTML được stream muộn. Ở Next.js 15 trở về trước, priority là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 set opacity: 0 lú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ùng next/font (mặc định display: '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:

  1. Quyết định ở server bằng cookie (cookies() trong Server Component), để HTML đầu tiên đã có hoặc không có banner.
  2. 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.
  3. 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). loadState cho 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ửi pathname, 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òn priority rải rác ở các ảnh khác.
  • Trang có TTFB cao được cache (revalidate hoặc 'use cache') và phần chậm được stream sau Suspense.
  • Mọi fill image, iframe, slot quảng cáo đều có aspect-ratio hoặc min-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 trong startTransition.
  • Script bên thứ ba đã được rà lại strategy; chat widget dùng lazyOnload hoặc facade.
  • Lighthouse CI chạy trên PR với ngưỡng LCP, CLS, TBT.

Đọc thêm

Last updated: July 21, 2025

Comments