jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Core Web Vitals & INP: A Measurement-and-Remediation Playbook for Production

Field data beats lab data — a principal-engineer playbook for measuring LCP, INP, and CLS in production and fixing what CrUX actually scores.

Core Web Vitals (CWV) là ba metric Google dùng để đo trải nghiệm trang của user thật trên Chrome. Chúng không thay thế chất lượng sản phẩm, nhưng là metric duy nhất đi vào tín hiệu Page Experience trên Search và dashboard CrUX mà leadership đọc. Bài này là playbook đo lường và khắc phục cho senior engineer: CWV được định nghĩa thế nào, vì sao field data lệch lab, và cách sửa từng metric mà không áp dụng mù quáng gợi ý Lighthouse.

Phạm vi: Bài này chỉ cover đo lường và khắc phục CWV. Chiến lược cache, tối ưu CSS chi tiết, skeleton screen, và font loading có bài riêng — ta chỉ nhắc khi giao với một vital cụ thể.


Ba Core Web Vitals năm 2026

Từ tháng 3/2024, Interaction to Next Paint (INP) thay First Input Delay (FID) làm vital về độ phản hồi. FID chỉ đo delay của input đầu tiên khi load trang; INP đo latency tương tác tệ nhất trong cả phiên truy cập. Thay đổi thiết kế đó khiến INP khó hơn nhiều — và trung thực hơn với SPA và session dài.

MetricWhat it measuresGoodNeeds improvementPoor
LCP (Largest Contentful Paint)Time until the largest above-the-fold content element is painted≤ 2.5 s2.5 – 4.0 s> 4.0 s
INP (Interaction to Next Paint)Worst interaction latency (input → next frame) during the visit≤ 200 ms200 – 500 ms> 500 ms
CLS (Cumulative Layout Shift)Sum of unexpected layout shift scores without recent user input≤ 0.10.1 – 0.25> 0.25

Cả ba ngồi trên percentile 75 (p75) của session user thật trong cửa sổ 28 ngày lăn. Đạt hai trong ba không được tính pass — Google đánh giá từng vital độc lập cho Page Experience.

  Page load timeline                    Interaction timeline
  ─────────────────                     ────────────────────
  [TTFB][Resource][Render]──► LCP        [Input][Process][Paint]──► INP
  ─────────────────                     ────────────────────
  Visual stability (entire visit) ──────────────────────────────► CLS

Lab vs field: vì sao điểm Lighthouse lừa bạn

Lab data từ test tổng hợp: Lighthouse, WebPageTest, hoặc CI trên phần cứng và profile mạng kiểm soát. Field data (Real User Monitoring, RUM) từ session thật ngoài đời. Dataset field công khai của Google là Chrome User Experience Report (CrUX) — metric gom, ẩn danh từ user Chrome opt-in.

DimensionLabField (CrUX / RUM)
NetworkThrottled preset (e.g. Slow 4G)User’s actual connection — 3G, Wi-Fi, corporate VPN
DeviceEmulated Moto G4 or MacBook ProLong tail of Android mid-range, old iPads, budget laptops
Cache stateUsually cold cacheWarm cache, bfcache restores, Service Worker hits
InteractionsNone (LCP/CLS only unless scripted)Real taps, typing, rage clicks, multi-step flows
AggregationSingle runp75 over millions of sessions

Chọn p75 là có chủ ý. Median (p50) che latency đuôi làm một phần tư user khó chịu. p95 quá nhiễu cho tín hiệu xếp hạng ổn định. p75 là điểm cân: “đa số user trải nghiệm tốt, nhưng vẫn phạt đau hệ thống ở đuôi”.

Lab lừa theo cách dự đoán được:

  • CDN ấm, user lạnh: CI chạy từ data center cách origin 5 ms; user Đông Nam Á qua last mile nghẽn.
  • Không auth, không cá nhân hóa: Lab vào trang marketing tĩnh; production serve bootstrap JSON 400 KB cho dashboard đã login.
  • Third-party không biến thiên: Lab chặn ads; production inject 2 MB script tag-manager sau consent.
  • FID ≠ INP: Lighthouse vẫn báo TBT và đôi khi proxy FID; không dự đoán INP trên app nhiều tương tác.

Quy tắc principal engineer: Coi lab là hàng rào regression trong CI; coi field là nguồn sự thật cho ưu tiên và báo cáo lên leadership.


LCP deep dive: tìm element, sửa pipeline

LCP báo thời điểm render element contentful lớn nhất nhìn thấy trong viewport lúc load ban đầu. Ứng viên gồm <img>, poster <video>, phần tử block có background image, và text node trong container block. SVG và <canvas> không là ứng viên LCP theo spec hiện tại.

Element LCP có thể đổi khi load — observer báo nhiều entry, entry cuối trước khi user tương tác hoặc visibilitychange sang hidden mới thắng. Hero image load muộn có thể cướp LCP từ headline đã paint trước.

LCP sub-parts (breakdown quan trọng)

Chrome expose bốn sub-part qua bản attribution của web-vitals và Performance API:

Sub-partDefinitionTypical root cause
Time to First Byte (TTFB)Navigation start → first response byteSlow origin, cold serverless, missing CDN, heavy SSR
Resource load delayTTFB → resource fetch startLow fetchpriority, discovery late in HTML, JS blocking parser
Resource load durationFetch start → last byteLarge unoptimized image, slow CDN POP, HTTP/1.1 head-of-line
Element render delayLast byte → paintMain-thread long tasks, render-blocking CSS, font blocking

Sửa từng sub-part độc lập — tối ưu image vô ích nếu TTFB 1.8 giây.

Khắc phục theo sub-part

TTFB: Đưa shell tĩnh ra edge (SSG, ISR, edge SSR). Giữ xử lý server HTML trên critical path dưới 200 ms. Dùng early hints (103) và connection warming cho SSR nặng API nếu không pre-render được.

Resource load delay: Đặt resource LCP trong chunk HTML đầu — không sau client router hay consent gate. Dùng fetchpriority="high" trên <img> LCP và tránh lazy-load nó.

<!-- LCP image: discover early, fetch with high priority -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
<img
  src="/hero.avif"
  width="1200"
  height="630"
  fetchpriority="high"
  decoding="async"
  alt="Product dashboard"
/>

Resource load duration: Serve AVIF/WebP với srcset responsive. Size image đúng kích thước hiển thị — nguồn 4000 px trên slot 400 px lãng phí byte và thời gian decode. Đặt image LCP trên cùng hostname CDN để tái sử dụng connection.

Element render delay: Inline critical CSS cho above-the-fold; defer phần còn lại. Chia nhỏ JS đồng bộ trên main thread (xem phần INP). Với framework SSR, dùng streaming để browser parse HTML và bắt đầu fetch resource LCP trước khi response đầy đủ tới.

// Astro / React SSR streaming pattern (conceptual)
const stream = await renderToReadableStream(<App />);
return new Response(stream, {
  headers: { 'Content-Type': 'text/html; charset=utf-8' },
});

Failure mode LCP principal gặp trên production

  • Carousel hero: Image slide 2 thành LCP sau autoplay — tối ưu slide 1 vẫn fail.
  • LCP client-render: React hydrate trước khi <img> hero có trong DOM — LCP dời sang skeleton hoặc text footer.
  • Soft navigation: Đổi route SPA không reset LCP cho CrUX (LCP gắn navigation), nhưng phá cảm giác nhanh — đừng nhầm hai thứ.

INP deep dive: chuyển FID → INP và thứ thực sự làm hỏng

FID đo (processingStart - startTime) chỉ cho lần keydown, mousedown, pointerdown, hoặc touchstart đầu tiên. Nó bỏ qua mọi thứ sau hydrate trên dashboard nặng — đúng chỗ user cảm thấy đau.

INP ghi mọi tương tác đủ điều kiện trong lifecycle trang và báo chậm nhất (hoặc percentile 98 ở một số tool — CrUX dùng tương tác tệ nhất mỗi visit). Tương tác đủ điều kiện là click, tap, và phím kích handler — không gồm scroll hay hover thụ động.

INP sub-parts

Mỗi latency tương tác tách thành ba phase:

PhaseWhat happensFix lever
Input delayMain thread busy — input waits in queueShorter tasks, scheduler.yield(), defer non-urgent work
Processing durationYour event handlers + framework reconciliation runDebounce vs yield, Web Workers, startTransition
Presentation delayStyle recalc, layout, paint, composite until next frameReduce DOM size, avoid layout thrashing, simplify selectors
  User tap


  [Input delay]──────► main thread blocked by 120ms long task


  [Processing]───────► onClick → setState → 1000-node reconcile


  [Presentation]─────► layout + paint blocked by remaining work


  Next frame visible to user  ◄── INP measures up to here

Long task: kẻ thù của INP

Mọi task main thread vượt 50 mslong task và có thể trễ xử lý input. Một long task 200 ms lúc click cộng tới 200 ms input delay trước khi handler chạy.

// Detect long tasks in production
const longTaskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      report({
        type: 'longtask',
        duration: entry.duration,
        startTime: entry.startTime,
        // attribution available on Long Task Timing Level 2 in supporting browsers
        attribution: entry.attribution,
      });
    }
  }
});
longTaskObserver.observe({ type: 'longtask', buffered: true });

Chrome chuyển sang quan sát Long Animation Frame (LoAF), gán URL script và invoker — dùng khi có để phân tích root cause nhanh hơn.

Chia nhỏ work: yield, idle, và scheduler

scheduler.yield() (Scheduler API) chủ động nhường cho browser để input đang chờ chạy. Dùng trong vòng lặp hoặc work client nhiều bước sẽ chiếm main thread.

async function processLargeList(items) {
  for (const item of items) {
    processItem(item);

    // Yield every iteration so clicks/taps are not starved
    if ('scheduler' in globalThis && 'yield' in scheduler) {
      await scheduler.yield();
    }
  }
}

isInputPending() (Experimental — kiểm tra support trước khi ship) gợi ý browser có input đang chờ. Ghép với yield trong idle callback để ưu tiên phản hồi hơn work nền.

function runDeferredWork(deadline) {
  while (deadline.timeRemaining() > 0 && workQueue.length) {
    if (navigator.scheduling?.isInputPending?.()) {
      // Stop — user is trying to interact
      scheduleIdleContinuation();
      return;
    }
    workQueue.shift()();
  }
  if (workQueue.length) scheduleIdleContinuation();
}

function scheduleIdleContinuation() {
  requestIdleCallback(runDeferredWork, { timeout: 2000 });
}

Debounce vs yield — giải hai bài toán khác nhau. Debounce gom 50 keystroke thành một lần gọi handler — tốt cho autocomplete, tệ nếu UI đơ đến hết cửa sổ debounce. Yield giữ mỗi keystroke phản hồi trong khi trải work đắt qua nhiều frame. Với INP, ưu tiên yield + startTransition hơn debounce mạnh trên control tương tác.

Pattern INP riêng React

Tính năng Concurrent React 18+ map thẳng vào INP sub-parts:

  • startTransition: Đánh dấu cập nhật state không khẩn — React có thể ngắt cho input.
  • useDeferredValue: Hoãn re-render đắt do input đổi nhanh.
  • Selective hydration (cấp framework): Hydrate island tương tác trước nội dung inert.
import { startTransition, useState, useDeferredValue } from 'react';

function FilterableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  const filtered = filterItems(items, deferredQuery);

  return (
    <>
      <input
        value={query}
        onChange={(e) => {
          // Urgent: keep input snappy
          setQuery(e.target.value);
        }}
      />
      {/* Non-urgent: large list re-filter deferred */}
      <List items={filtered} />
    </>
  );
}

function TabPanel() {
  const [tab, setTab] = useState('overview');

  return (
    <nav>
      {TABS.map((t) => (
        <button
          key={t.id}
          onClick={() => {
            startTransition(() => setTab(t.id));
          }}
        >
          {t.label}
        </button>
      ))}
    </nav>
  );
}

Failure mode: bọc mọi thứ trong startTransition — UI khẩn (toast, focus ring, validate form) chậm vì React hạ ưu tiên quá mức. Chỉ defer update gây commit subtree lớn.

INP trên trang không có tương tác

Nếu trang không có tương tác đủ điều kiện trong visit, INP không được báo — CrUX bỏ qua thay vì chấm zero. Bài chỉ đọc thường pass INP mặc định; app shell fail vì mỗi click đều quan trọng. Đừng bỏ qua INP trên site marketing có widget tương tác (máy tính, banner consent, chat).


CLS deep dive: ổn định là hợp đồng với layout

CLS cộng điểm layout shift bất ngờ cho shift không có hadRecentInput trong 500 ms. Mỗi điểm shift là impact fraction × distance fraction — dịch phần tử lớn một chút có thể tệ hơn phần tử nhỏ dịch xa.

Nguồn CLS phổ biến

SourceMechanismFix
Images without dimensionsBrowser reserves 0×0 until loadwidth/height or aspect-ratio
Web fonts (FOUT/FOIT)Fallback → webfont swap changes metricssize-adjust, subset, optional display
Injected contentBanners, toasts, ads push content downReserve space with min-height or slot
iframe/embedsLate-loading embed expands containerAspect-ratio box, skeleton slot
Animationstop/left/height animate layoutAnimate transform and opacity only
SPA route transitionsNew view mounts with different heightConsistent shell, cross-fade in fixed container
/* Reserve space before image loads */
.hero {
  aspect-ratio: 1200 / 630;
  width: 100%;
  max-width: 1200px;
}

.hero img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

CLS do font mà không lặp lại bài font loading

Khi webfont swap vào và text reflow, CLS tăng vọt. size-adjust trên @font-face chỉnh metric fallback khớp advance width webfont. Tool như Fallback Font Generator tính giá trị; xác minh trong vùng Layout Shift DevTools.

@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

bfcache và đo CLS

Khi trang restore từ back/forward cache (bfcache), layout đã ổn — CLS navigation gốc không cộng lại. Tuy nhiên shift mới sau restore (vd ads inject lại) vẫn tính vào CLS session đó. Tránh listener unloadCache-Control: no-store trên document cần bfcache — thường giết bfcache và làm back chậm cảm giác.

// Check bfcache eligibility failures in DevTools or via PerformanceNavigationTiming
const nav = performance.getEntriesByType('navigation')[0];
if (nav.notRestoredReasons) {
  console.table(nav.notRestoredReasons.reasons);
}

Đo production: web-vitals, attribution, và PerformanceObserver

Đừng tự viết công thức Web Vitals trừ khi bạn thích edge case spec. Thư viện web-vitals implement thuật toán chuẩn, xử lý visibility change, khớp methodology CrUX.

Wiring RUM cơ bản

import { onCLS, onINP, onLCP } from 'web-vitals';

type VitalPayload = {
  name: string;
  value: number;
  id: string;
  rating: 'good' | 'needs-improvement' | 'poor';
  delta: number;
  navigationType: string;
};

function sendToAnalytics(metric: VitalPayload) {
  const body = JSON.stringify({
    ...metric,
    page: location.pathname,
    ts: Date.now(),
  });

  // sendBeacon survives page unload; fetch keepalive as fallback
  if (navigator.sendBeacon?.('/api/vitals', body)) return;

  fetch('/api/vitals', {
    body,
    method: 'POST',
    keepalive: true,
    headers: { 'Content-Type': 'application/json' },
  });
}

onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);

Báo cáo khi visibilitychange sang hidden — không beforeunload (bị chặn trên nhiều mobile browser, phá bfcache). Thư viện finalize metric tại thời điểm đó.

Bản attribution: data debug bạn thực sự cần

Import từ web-vitals/attribution để có breakdown sub-part trong cùng callback:

import { onINP, onLCP } from 'web-vitals/attribution';

onLCP((metric) => {
  const { element, url, timeToFirstByte, resourceLoadDelay, resourceLoadDuration, elementRenderDelay } =
    metric.attribution;

  sendToAnalytics({
    ...metric,
    lcpElement: element?.tagName,
    lcpUrl: url,
    ttfb: timeToFirstByte,
    loadDelay: resourceLoadDelay,
    loadDuration: resourceLoadDuration,
    renderDelay: elementRenderDelay,
  });
});

onINP((metric) => {
  const { interactionTarget, interactionType, inputDelay, processingDuration, presentationDelay } =
    metric.attribution;

  sendToAnalytics({
    ...metric,
    target: interactionTarget,
    type: interactionType,
    inputDelay,
    processingDuration,
    presentationDelay,
  });
});

Đưa field attribution vào warehouse analytics — gom theo lcpUrlinteractionTarget để tìm 3 element gây 80% đau. Đây là cách principal engineer tránh “LCP lab giảm 200 ms nhưng CrUX không động”.

PerformanceObserver thuần khi cần thêm

Thư viện web-vitals dùng PerformanceObserver bên trong; hạ xuống khi cần entry type tuỳ chỉnh:

function observe<T extends PerformanceEntry>(
  type: string,
  callback: (entries: T[]) => void,
): PerformanceObserver | null {
  try {
    const observer = new PerformanceObserver((list) => {
      callback(list.getEntries() as T[]);
    });
    observer.observe({ type, buffered: true });
    return observer;
  } catch {
    return null;
  }
}

// INP uses Event Timing — observe 'event' entries with duration
observe<PerformanceEventTiming>('event', (entries) => {
  for (const entry of entries) {
    if (entry.duration > 104) {
      // 104ms ≈ one frame at 60Hz + processing budget
      console.warn('Slow interaction', entry.name, entry.duration, entry.target);
    }
  }
});

// Layout shifts with sources (CLS debugging)
observe<LayoutShift>('layout-shift', (entries) => {
  for (const entry of entries) {
    if (entry.hadRecentInput) continue;
    for (const source of entry.sources ?? []) {
      console.log('Shift', entry.value, source.node, source.previousRect, source.currentRect);
    }
  }
});

Luôn truyền buffered: true — nếu không sẽ miss entry fire trước khi script observer chạy.

Sampling và privacy

RUM đầy đủ mọi session tốn kém khi scale. Sample 1–10% session cho attribution đầy đủ; luôn thu aggregate LCP/CLS/INP. Gỡ PII khỏi selector interactionTarget trước khi log — button#checkout ổn; input[name=ssn] thì không. Tuân consent framework: đừng gửi vitals sang analytics bên thứ ba trước consent nếu luật yêu cầu.


Framework ưu tiên cho principal engineer

Khi leadership bảo “sửa Core Web Vitals,” mở Lighthouse sửa điểm đỏ là bước sai. Bước đúng là chương trình có cấu trúc gắn field data.

Phase 0: Thiết lập sự thật (tuần 1)

  1. Lấy data CrUX cho origin trên PageSpeed Insights và CrUX Dashboard.
  2. Deploy web-vitals/attribution lên RUM với chiều page và device.
  3. Xác định vital nào fail ở p75 và trên template URL nào.
  4. So CrUX mobile vs desktop — mobile thường kéo điểm fail.

Phase 1: Quick win ROI rõ (tuần 2–3)

If failing…First interventions
LCPPreload + fetchpriority="high" on LCP image; fix TTFB if > 800 ms; remove lazy-load from above-fold hero
INPProfile longest LoAF/long tasks on top 3 interaction targets; add startTransition to tab/filter handlers
CLSAdd width/height or aspect-ratio to all above-fold media; reserve slot for consent/ad banners

Xác nhận trên field, không lab — ship sau flag nếu cần và so p75 28 ngày.

Phase 2: Sửa cấu trúc (tuần 4–8)

  • LCP: Streaming SSR, render edge, CDN image tự động format.
  • INP: Code-split JS theo route; chuyển tính toán nặng sang Web Worker; audit script bên thứ ba qua LoAF attribution.
  • CLS: Primitive design system có hợp đồng kích thước intrinsic; component slot ad có aspect ratio cố định.

Phase 3: Quản trị (liên tục)

  • Budget performance trong CI: LCP > 2.5 s hoặc JS > 200 KB trên route critical fail build.
  • Dashboard RUM xu hướng vital, không điểm một lần chạy.
  • Review bên thứ ba: mỗi tag mới đánh giá tác động INP trước merge.
  • Xử lý sự cố: CrUX tụt thì check tương quan deploy + breakdown attribution trước khi đoán.
  Decision tree (simplified)
  ──────────────────────────
  CrUX p75 failing?

       ├─ LCP ──► attribution: TTFB high? → origin/CDN
       │              load delay high? → preload/fetchpriority
       │              render delay high? → main-thread JS

       ├─ INP ──► attribution: input delay? → long tasks
       │              processing? → handler/framework work
       │              presentation? → layout/paint cost

       └─ CLS ──► shift sources in DevTools → fix top 3 nodes

Trade-off principal phải truyền đạt

  • Preload mọi thứ cải LCP một trang nhưng tranh bandwidth trang khác — preload chỉ resource LCP đã xác minh mỗi template.
  • Code-split mạnh giúp INP nhưng tăng round trip — cân với HTTP/2 multiplex và prefetch theo intent.
  • Giữ chỗ cho ads cải CLS nhưng giảm viewability — product phải chọn.
  • Tối ưu INP trên React thêm gánh nặng nhận thức (useTransition, deferred value) — document pattern trong design system.

Checklist trước khi tuyên bố thắng

  • CrUX p75 Good cho LCP, INP, CLS trên mobile (chiều khắt khe hơn)
  • Attribution RUM đã deploy; URL LCP top và interaction target INP top đã ghi nhận
  • Budget lab CI chặn regression nhưng không phải metric thành công
  • bfcache không bị phá bởi handler unload hay no-store trên HTML
  • Script bên thứ ba đã audit bằng LoAF/long-task attribution
  • Product, design, eng thống nhất giữ chỗ CLS (ads, embed, UI động)

Core Web Vitals nén trải nghiệm user thành ba con số — không hoàn hảo, nhưng vận hành được. Việc principal engineer không phải đuổi điểm Lighthouse xanh; mà xây vòng đo tìm đau thật, sửa sub-part đòn bẩy cao nhất, và giữ regression ra production bằng budget và RUM. Field data thắng lab data — đo đúng thứ CrUX đo, sửa đúng thứ attribution chỉ ra, rồi ship.


Đọc thêm