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) are the three metrics Google uses to quantify real-user page experience in Chrome {Core Web Vitals (CWV) là ba metric Google dùng để đo trải nghiệm trang của user thật trên Chrome}. They are not a substitute for product quality, but they are the only metrics that feed Page Experience signals in Search and the CrUX dashboard your leadership reads {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}. This post is a measurement-and-remediation playbook for senior engineers: how CWV are defined, why field data diverges from lab, and how to fix each metric without cargo-culting Lighthouse suggestions {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}.

Scope {Phạm vi}: This article covers CWV measurement and remediation only {Bài này chỉ cover đo lường và khắc phục CWV}. Caching strategy, CSS micro-optimization, skeleton screens, and font loading have dedicated posts elsewhere — we reference them only where they intersect with a specific vital {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ể}.


The three Core Web Vitals in 2026 {Ba Core Web Vitals năm 2026}

Since March 2024, Interaction to Next Paint (INP) replaced First Input Delay (FID) as the responsiveness vital {Từ tháng 3/2024, Interaction to Next Paint (INP) thay First Input Delay (FID) làm vital về độ phản hồi}. FID measured only the first input delay on a page load; INP measures the worst interaction latency across the entire page visit {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}. That single design change makes INP a much harder metric — and a much more honest one for SPAs and long sessions {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

All three thresholds apply at the 75th percentile (p75) of real-user sessions over a rolling 28-day window {Cả ba ngồi trên percentile 75 (p75) của session user thật trong cửa sổ 28 ngày lăn}. Passing two of three is not a pass — Google evaluates each vital independently for the Page Experience assessment {Đạ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: why your Lighthouse score lies {Lab vs field: vì sao điểm Lighthouse lừa bạn}

Lab data comes from synthetic tests: Lighthouse, WebPageTest, or CI runs on controlled hardware and network profiles {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) comes from actual sessions in the wild {Field data (Real User Monitoring, RUM) từ session thật ngoài đời}. Google’s public field dataset is the Chrome User Experience Report (CrUX) — aggregated, anonymized metrics from opted-in Chrome users {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

The p75 choice is deliberate {Chọn p75 là có chủ ý}. Median (p50) hides tail latency that frustrates a quarter of your users {Median (p50) che latency đuôi làm một phần tư user khó chịu}. p95 is too noisy for stable ranking signals {p95 quá nhiễu cho tín hiệu xếp hạng ổn định}. p75 is the sweet spot: “most users have a good experience, but we still penalize systemic tail pain” {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 can lie in predictable ways {Lab lừa theo cách dự đoán được}:

  • Warm CDN, cold user: Your CI runs from a data center 5 ms from the origin; users in Southeast Asia hit a congested last mile {CDN ấm, user lạnh: CI chạy từ data center cách origin 5 ms; user Đông Nam Á qua last mile nghẽn}.
  • No auth, no personalization: Lab hits a static marketing page; production serves a 400 KB JSON bootstrap for logged-in dashboards {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}.
  • No third-party variance: Lab blocks ads; production injects 2 MB of tag-manager scripts after consent {Third-party không biến thiên: Lab chặn ads; production inject 2 MB script tag-manager sau consent}.
  • FID ≠ INP: Lighthouse still reports TBT and sometimes FID proxies; neither predicts INP on interaction-heavy apps {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}.

Principal-engineer rule {Quy tắc principal engineer}: Treat lab as a regression guard in CI; treat field as the source of truth for prioritization and executive reporting {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: find the element, fix the pipeline {LCP deep dive: tìm element, sửa pipeline}

LCP reports the render time of the largest contentful element visible in the viewport during the initial load {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}. Candidates include <img>, <video> poster, block-level elements with background images, and text nodes in block containers {Ứng viên gồm <img>, poster <video>, phần tử block có background image, và text node trong container block}. SVG and <canvas> are not LCP candidates as of current spec {SVG và <canvas> không là ứng viên LCP theo spec hiện tại}.

The LCP element can change during load — the observer reports multiple entries, and the last one before user interaction or visibilitychange to hidden wins {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}. A hero image that loads late can steal LCP from a headline that painted first {Hero image load muộn có thể cướp LCP từ headline đã paint trước}.

LCP sub-parts (the breakdown that matters) {LCP sub-parts (breakdown quan trọng)}

Chrome exposes four sub-parts via the web-vitals attribution build and Performance API {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

Fix each sub-part independently — optimizing images does nothing if TTFB is 1.8 s {Sửa từng sub-part độc lập — tối ưu image vô ích nếu TTFB 1.8 giây}.

Remediation by sub-part {Khắc phục theo sub-part}

TTFB {TTFB}: Move static shells to the edge (SSG, ISR, edge SSR) {Đưa shell tĩnh ra edge (SSG, ISR, edge SSR)}. Keep server work under 200 ms for HTML documents on the critical path {Giữ xử lý server HTML trên critical path dưới 200 ms}. Use early hints (103) and connection warming for API-heavy SSR if you cannot pre-render {Dùng early hints (103) và connection warming cho SSR nặng API nếu không pre-render được}.

Resource load delay {Resource load delay}: Put the LCP resource in the first HTML chunk — not behind a client-side router or consent gate {Đặt resource LCP trong chunk HTML đầu — không sau client router hay consent gate}. Use fetchpriority="high" on the LCP <img> and avoid lazy-loading it {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 {Resource load duration}: Serve AVIF/WebP with responsive srcset {Serve AVIF/WebP với srcset responsive}. Size images to displayed dimensions — a 4000 px source on a 400 px slot wastes bytes and decode time {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}. Put LCP images on the same CDN hostname to reuse connections {Đặt image LCP trên cùng hostname CDN để tái sử dụng connection}.

Element render delay {Element render delay}: Inline critical CSS for above-the-fold content; defer everything else {Inline critical CSS cho above-the-fold; defer phần còn lại}. Break up synchronous JS on the main thread (see INP section) {Chia nhỏ JS đồng bộ trên main thread (xem phần INP)}. For SSR frameworks, use streaming so the browser can parse HTML and start fetching LCP resources before the full response arrives {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' },
});

LCP failure modes principals see in production {Failure mode LCP principal gặp trên production}

  • Carousel hero: Slide 2’s image becomes LCP after autoplay — optimize slide 1 only and you still fail {Carousel hero: Image slide 2 thành LCP sau autoplay — tối ưu slide 1 vẫn fail}.
  • Client-rendered LCP: React hydrates before the hero <img> exists in DOM — LCP shifts to a skeleton or footer text {LCP client-render: React hydrate trước khi <img> hero có trong DOM — LCP dời sang skeleton hoặc text footer}.
  • Soft navigations: SPA route changes do not reset LCP for CrUX (LCP is navigation-bound), but they destroy perceived performance — do not conflate the two {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: the FID → INP transition and what actually breaks {INP deep dive: chuyển FID → INP và thứ thực sự làm hỏng}

FID measured (processingStart - startTime) for the first keydown, mousedown, pointerdown, or touchstart only {FID đo (processingStart - startTime) chỉ cho lần keydown, mousedown, pointerdown, hoặc touchstart đầu tiên}. It ignored everything after hydration on a heavy dashboard — exactly where users feel pain {Nó bỏ qua mọi thứ sau hydrate trên dashboard nặng — đúng chỗ user cảm thấy đau}.

INP captures every qualifying interaction during the page lifecycle and reports the slowest (or 98th percentile in some tooling — CrUX uses the worst interaction per visit) {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)}. Qualifying interactions are clicks, taps, and key presses that trigger event handlers — not scroll or passive hover {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 {INP sub-parts}

Each interaction latency decomposes into three phases {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 tasks: the INP enemy {Long task: kẻ thù của INP}

Any task on the main thread exceeding 50 ms is a long task and can delay input processing {Mọi task main thread vượt 50 mslong task và có thể trễ xử lý input}. One 200 ms task during a click adds up to 200 ms of input delay before your handler even runs {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 is migrating toward Long Animation Frame (LoAF) observation, which attributes script URLs and invoker types — use it when available for faster root-cause analysis {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}.

Breaking up work: yield, idle, and the scheduler {Chia nhỏ work: yield, idle, và scheduler}

scheduler.yield() (Scheduler API) explicitly yields to the browser so pending input can run {scheduler.yield() (Scheduler API) chủ động nhường cho browser để input đang chờ chạy}. Use it inside loops or multi-step client work that would otherwise monopolize the main thread {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 — check support before shipping) hints whether the browser has pending input {isInputPending() (Experimental — kiểm tra support trước khi ship) gợi ý browser có input đang chờ}. Pair it with yielding in idle callbacks to prioritize responsiveness over background work {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 });
}

Debouncing vs yielding — they solve different problems {Debounce vs yield — giải hai bài toán khác nhau}. Debouncing collapses 50 keystrokes into one handler call — good for search autocomplete, bad if the UI feels frozen until the debounce window closes {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}. Yielding keeps every keystroke responsive while spreading expensive work across frames {Yield giữ mỗi keystroke phản hồi trong khi trải work đắt qua nhiều frame}. For INP, prefer yield + startTransition over aggressive debounce on interactive controls {Với INP, ưu tiên yield + startTransition hơn debounce mạnh trên control tương tác}.

React-specific INP patterns {Pattern INP riêng React}

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

  • startTransition: Marks state updates as non-urgent — React can interrupt them for input {startTransition: Đánh dấu cập nhật state không khẩn — React có thể ngắt cho input}.
  • useDeferredValue: Defers expensive re-renders driven by fast-changing input {useDeferredValue: Hoãn re-render đắt do input đổi nhanh}.
  • Selective hydration (framework-level): Hydrate interactive islands before inert content {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: wrapping everything in startTransition — urgent UI (toasts, focus rings, form validation) becomes sluggish because React deprioritizes it too {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}. Only defer updates that cause large subtree commits {Chỉ defer update gây commit subtree lớn}.

INP on pages with no interactions {INP trên trang không có tương tác}

If a page has no qualifying interactions during a visit, INP is not reported for that session — CrUX omits it rather than scoring zero {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}. Content-only articles often pass INP by default; app shells fail because every click matters {Bài chỉ đọc thường pass INP mặc định; app shell fail vì mỗi click đều quan trọng}. Do not ignore INP on marketing sites with interactive widgets (calculators, consent banners, chat widgets) {Đừng bỏ qua INP trên site marketing có widget tương tác (máy tính, banner consent, chat)}.


CLS deep dive: stability is a contract with the layout {CLS deep dive: ổn định là hợp đồng với layout}

CLS sums unexpected layout shift scores for shifts without hadRecentInput within 500 ms {CLS cộng điểm layout shift bất ngờ cho shift không có hadRecentInput trong 500 ms}. Each shift score is impact fraction × distance fraction — moving a large element a small distance can score worse than moving a tiny element far {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}.

Common CLS sources {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;
}

When a webfont swaps in and text reflows, CLS spikes {Khi webfont swap vào và text reflow, CLS tăng vọt}. size-adjust on @font-face nudges fallback metrics to match the webfont’s advance widths {size-adjust trên @font-face chỉnh metric fallback khớp advance width webfont}. Tools like Fallback Font Generator compute values; verify in DevTools Layout Shift regions {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 and CLS measurement {bfcache và đo CLS}

When a page restores from back/forward cache (bfcache), layout is already settled — CLS from the original navigation does not re-accumulate {Khi trang restore từ back/forward cache (bfcache), layout đã ổn — CLS navigation gốc không cộng lại}. However, new shifts after restore (e.g. re-injected ads) count toward that session’s CLS {Tuy nhiên shift mới sau restore (vd ads inject lại) vẫn tính vào CLS session đó}. Avoid unload listeners and Cache-Control: no-store on documents you want bfcache-eligible — they are common bfcache killers that also hurt perceived back-navigation speed {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);
}

Measuring in production: web-vitals, attribution, and PerformanceObserver {Đo production: web-vitals, attribution, và PerformanceObserver}

Do not hand-roll Web Vitals math unless you enjoy spec edge cases {Đừng tự viết công thức Web Vitals trừ khi bạn thích edge case spec}. The web-vitals library implements the canonical algorithms, handles visibility changes, and matches CrUX methodology {Thư viện web-vitals implement thuật toán chuẩn, xử lý visibility change, khớp methodology CrUX}.

Basic RUM wiring {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);

Report on visibilitychange to hidden — not beforeunload (blocked on many mobile browsers, breaks bfcache) {Báo cáo khi visibilitychange sang hidden — không beforeunload (bị chặn trên nhiều mobile browser, phá bfcache)}. The library finalizes metrics at that point {Thư viện finalize metric tại thời điểm đó}.

Attribution build: the debug data you actually need {Bản attribution: data debug bạn thực sự cần}

Import from web-vitals/attribution to get sub-part breakdowns in the same callback {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,
  });
});

Ship attribution fields to your analytics warehouse — aggregate by lcpUrl and interactionTarget to find the 3 elements causing 80% of pain {Đưa field attribution vào warehouse analytics — gom theo lcpUrlinteractionTarget để tìm 3 element gây 80% đau}. This is how principal engineers avoid “we improved LCP by 200 ms globally but CrUX didn’t move” {Đây là cách principal engineer tránh “LCP lab giảm 200 ms nhưng CrUX không động”}.

Raw PerformanceObserver when you need more {PerformanceObserver thuần khi cần thêm}

The web-vitals library uses PerformanceObserver internally; drop down when you need custom entry types {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);
    }
  }
});

Always pass buffered: true — otherwise you miss entries that fired before your observer script executed {Luôn truyền buffered: true — nếu không sẽ miss entry fire trước khi script observer chạy}.

Sampling and privacy {Sampling và privacy}

Full RUM on every session is expensive at scale {RUM đầy đủ mọi session tốn kém khi scale}. Sample 1–10% of sessions for full attribution; always collect LCP/CLS/INP aggregates {Sample 1–10% session cho attribution đầy đủ; luôn thu aggregate LCP/CLS/INP}. Strip PII from interactionTarget selectors before logging — button#checkout is fine; input[name=ssn] is not {Gỡ PII khỏi selector interactionTarget trước khi log — button#checkout ổn; input[name=ssn] thì không}. Respect consent frameworks: do not send vitals to third-party analytics before consent where legally required {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}.


A prioritization framework for principal engineers {Framework ưu tiên cho principal engineer}

When leadership says “fix Core Web Vitals,” the wrong move is opening Lighthouse and fixing whatever is red {Khi leadership bảo “sửa Core Web Vitals,” mở Lighthouse sửa điểm đỏ là bước sai}. The right move is a structured program tied to field data {Bước đúng là chương trình có cấu trúc gắn field data}.

Phase 0: Establish truth (week 1) {Phase 0: Thiết lập sự thật (tuần 1)}

  1. Pull CrUX data for your origin in PageSpeed Insights and CrUX Dashboard {Lấy data CrUX cho origin trên PageSpeed Insights và CrUX Dashboard}.
  2. Deploy web-vitals/attribution to RUM with page-level and device-level dimensions {Deploy web-vitals/attribution lên RUM với chiều page và device}.
  3. Identify which vital fails at p75 and on which URL templates {Xác định vital nào fail ở p75 và trên template URL nào}.
  4. Compare CrUX mobile vs desktop — mobile usually drives the failing score {So CrUX mobile vs desktop — mobile thường kéo điểm fail}.

Phase 1: Quick wins with proven ROI (weeks 2–3) {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

Validate in field, not lab — ship behind a flag if needed and compare p75 over 28 days {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: Structural fixes (weeks 4–8) {Phase 2: Sửa cấu trúc (tuần 4–8)}

  • LCP: Streaming SSR, edge rendering, image CDN with automatic format negotiation {LCP: Streaming SSR, render edge, CDN image tự động format}.
  • INP: Code-split route-level JS; move heavy computation to Web Workers; audit third-party scripts with LoAF attribution {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: Design-system primitives with intrinsic size contracts; ad slot components with fixed aspect ratios {CLS: Primitive design system có hợp đồng kích thước intrinsic; component slot ad có aspect ratio cố định}.

Phase 3: Governance (ongoing) {Phase 3: Quản trị (liên tục)}

  • Performance budgets in CI: LCP > 2.5 s or JS > 200 KB on critical routes fails the build {Budget performance trong CI: LCP > 2.5 s hoặc JS > 200 KB trên route critical fail build}.
  • RUM dashboards with vital trends, not single-run scores {Dashboard RUM xu hướng vital, không điểm một lần chạy}.
  • Third-party review: every new tag gets an INP impact assessment before merge {Review bên thứ ba: mỗi tag mới đánh giá tác động INP trước merge}.
  • Incident response: when CrUX drops, check deploy correlation + attribution breakdown before guessing {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-offs principals must communicate {Trade-off principal phải truyền đạt}

  • Preload everything improves LCP on one page but contends bandwidth on others — preload only the verified LCP resource per template {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}.
  • Aggressive code-splitting helps INP but increases round trips — balance with HTTP/2 multiplexing and prefetch on intent {Code-split mạnh giúp INP nhưng tăng round trip — cân với HTTP/2 multiplex và prefetch theo intent}.
  • Reserving space for ads improves CLS but reduces ad viewability — product must choose {Giữ chỗ cho ads cải CLS nhưng giảm viewability — product phải chọn}.
  • INP optimization in React adds mental overhead (useTransition, deferred values) — document patterns in your design system {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 before you declare victory {Checklist trước khi tuyên bố thắng}

  • CrUX p75 Good for LCP, INP, and CLS on mobile (the stricter dimension) {CrUX p75 Good cho LCP, INP, CLS trên mobile (chiều khắt khe hơn)}
  • RUM attribution deployed; top LCP URL and top INP interaction target documented {Attribution RUM đã deploy; URL LCP top và interaction target INP top đã ghi nhận}
  • Lab CI budget prevents regression but is not the success metric {Budget lab CI chặn regression nhưng không phải metric thành công}
  • bfcache not broken by unload handlers or no-store on HTML {bfcache không bị phá bởi handler unload hay no-store trên HTML}
  • Third-party scripts audited with LoAF/long-task attribution {Script bên thứ ba đã audit bằng LoAF/long-task attribution}
  • Product, design, and eng aligned on CLS reservations (ads, embeds, dynamic UI) {Product, design, eng thống nhất giữ chỗ CLS (ads, embed, UI động)}

Core Web Vitals are a compression of user experience into three numbers — imperfect, but operational {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}. The principal-engineer job is not to chase green Lighthouse scores; it is to build a measurement loop that finds real pain, fix the highest-leverage sub-parts, and keep regressions out of production with budgets and RUM {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 beats lab data — measure what CrUX measures, fix what attribution names, and ship {Field data thắng lab data — đo đúng thứ CrUX đo, sửa đúng thứ attribution chỉ ra, rồi ship}.


Further reading {Đọc thêm}