web-vitals.js v5 (เปิดตัวเมษายน 2025) วัด INP, LCP, CLS, TTFB, FCP ได้ครบและมี Attribution Build ที่ระบุ element และ script ที่ทำให้ค่าแย่
ข้อมูล synthetic จาก Lighthouse ใช้เครื่อง Moto G4 emulator ที่ไม่มีอยู่จริงในตลาดแล้ว ทำให้ค่าไม่สะท้อนผู้ใช้ p75 จริง
ต้องส่งข้อมูลด้วย navigator.sendBeacon() ในเหตุการณ์ visibilitychange เพราะ INP และ CLS จะ finalize ตอนหน้าถูกซ่อนเท่านั้น
ต้องแยก percentile p75 ตาม device class (mobile/desktop) และ effective connection type ไม่งั้นค่าจะเฉลี่ยกลบกัน
CrUX API ให้ข้อมูล origin-level ฟรี แต่ RUM ของคุณเองให้ page-level และ real-time ที่ CrUX ไม่มี
ต้อง sample rate อย่างน้อย 10% ของ session เพื่อได้ p75 ที่มีนัยสำคัญทางสถิติ
หัวข้อในบทความนี้
RUM คืออะไรและต่างจาก synthetic testing อย่างไร
ติดตั้ง web-vitals.js v5 ในโปรเจกต์
ส่งข้อมูลด้วย navigator.sendBeacon อย่างถูกวิธี
ใช้ Attribution Build เพื่อหาต้นเหตุ INP และ LCP
แยก percentile ตามอุปกรณ์และการเชื่อมต่อ
ส่งข้อมูลไปยัง Google Analytics 4
เปรียบเทียบ RUM ของคุณเองกับ CrUX API
กลยุทธ์ sampling และ cost control
RUM คืออะไรและต่างจาก synthetic testing อย่างไร
Real User Monitoring คือการเก็บข้อมูลประสิทธิภาพจากเบราว์เซอร์ของผู้ใช้จริงในสภาพแวดล้อมจริง ตรงกันข้ามกับ synthetic testing ที่จำลองผู้ใช้ในเครื่องทดสอบภายใต้เงื่อนไขที่ควบคุมได้ ในทางปฏิบัติ Lighthouse ใน CI ของคุณรันบน CPU throttled 4x และเครือข่าย Slow 4G emulated ซึ่งไม่ได้สะท้อนสภาพจริงของผู้ใช้ที่อยู่บน 3G ในต่างจังหวัด หรือผู้ใช้บน iPhone 15 Pro บน Wi-Fi 6
ตอนที่ผมเจอเคสแรกๆ RUM เผยความจริงที่ synthetic ปกปิดไว้ ยกตัวอย่างเช่น ผู้ใช้ Android รุ่นกลางกลุ่ม p75 เจอ INP เกิน 500ms แต่ Lighthouse ยังบอกว่าเป็น "Good" (ต่ำกว่า 200ms) อยู่เลย สาเหตุคือ third-party script โหลดแบบ async แต่รัน long task บน main thread ตอนที่ผู้ใช้เริ่ม interact ซึ่ง Lighthouse ไม่ได้จำลอง user gesture (ประเด็นสำคัญที่หลายคนมองข้าม)
ทำไม field data ถึงสำคัญกว่าในปี 2026
ตั้งแต่ Google เปลี่ยน FID เป็น INP เป็น Core Web Vital ในเดือนมีนาคม 2024 การวัด interaction latency จำเป็นต้องมี real user เพราะ INP วัดจาก interaction ทุกครั้งตลอดอายุของหน้า และรายงานค่าที่แย่ที่สุด synthetic tools ไม่สามารถจำลอง click, tap, keypress ครบทุกกรณีของ UI จริงได้ web.dev คู่มือ INP อย่างเป็นทางการ ยืนยันว่า INP ต้องวัดจาก field data เท่านั้นถึงจะเชื่อถือได้
ถ้าคุณยังไม่คุ้นกับการวินิจฉัย INP ให้อ่านคู่มือ Long Animation Frames API สำหรับดีบัก INP ก่อน เพราะบทความนี้จะสมมติว่าคุณเข้าใจว่า long task คืออะไรและ INP ประกอบด้วยส่วนไหนบ้าง
ติดตั้ง web-vitals.js v5 ในโปรเจกต์
web-vitals.js เป็นไลบรารี JavaScript ขนาดประมาณ 2 KB (gzip) ที่ Chrome team ดูแล ปัจจุบัน v5.0 (เมษายน 2025) รองรับ metric ครบทั้ง 5 ตัว: onLCP, onINP, onCLS, onTTFB, onFCP API เปลี่ยนจาก v3 ตรงที่ callback รับ object Metric ที่มี name, value, rating, delta, id, navigationType และ entries
ติดตั้งด้วย npm:
npm install web-vitals@^5.0.0
โค้ดพื้นฐานสำหรับเก็บค่าทั้ง 5 metric:
// src/rum.js
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';
const ENDPOINT = 'https://api.example.com/rum';
// สร้าง queue เพราะ metric ทยอยมา ไม่ควรยิงหลายครั้ง
const queue = new Set();
function sendMetrics() {
if (queue.size === 0) return;
const body = JSON.stringify({
url: location.pathname,
metrics: [...queue],
userAgent: navigator.userAgent,
connection: navigator.connection?.effectiveType || 'unknown',
deviceMemory: navigator.deviceMemory || 0,
timestamp: Date.now(),
});
// ใช้ sendBeacon เพราะ fetch อาจถูก cancel ตอน page unload
if (navigator.sendBeacon) {
navigator.sendBeacon(ENDPOINT, body);
} else {
fetch(ENDPOINT, { body, method: 'POST', keepalive: true });
}
queue.clear();
}
function handleMetric(metric) {
queue.add({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
}
onLCP(handleMetric);
onINP(handleMetric);
onCLS(handleMetric);
onTTFB(handleMetric);
onFCP(handleMetric);
// finalize + ส่งตอนหน้าถูกซ่อน (สำคัญมากสำหรับ INP/CLS)
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') sendMetrics();
});
// สำรอง: บาง browser ไม่ยิง visibilitychange ตอน close tab
addEventListener('pagehide', sendMetrics);
ข้อควรระวัง: อย่าใช้ beforeunload เป็น trigger เพราะ Safari และ Chrome เวอร์ชันใหม่ทำให้ bfcache ไม่ทำงานเมื่อมี listener นี้ ซึ่งจะทำให้ Core Web Vitals โดยรวมแย่ลง ใช้ visibilitychange + pagehide เท่านั้น
ส่งข้อมูลด้วย navigator.sendBeacon อย่างถูกวิธี
navigator.sendBeacon() เป็น browser API ที่ออกแบบมาเพื่อส่ง analytics โดยเฉพาะ ต่างจาก fetch() ตรงที่ browser จะทำให้ request สำเร็จแม้ว่าหน้าจะถูกปิดไปแล้ว payload สูงสุด 64 KB (RFC ไม่ได้กำหนด แต่ Chrome/Firefox limit ที่ 64 KB) และใช้ POST เท่านั้น
ข้อผิดพลาดที่ผมเจอบ่อยที่สุดในการติดตั้ง RUM คือการยิง metric ทีละตัวทันทีที่ callback ทำงาน ผลคือเกิด request 5+ ครั้งต่อ pageview และเมื่อผู้ใช้ปิด tab metric สุดท้าย (มักเป็น INP) จะหายไป วิธีถูกต้องคือใช้ queue แล้ว flush ทีเดียวตอน visibilitychange
รูปแบบ payload ที่ควรส่ง
อย่าส่งแค่ชื่อ metric กับค่า ให้ส่ง context ประกอบด้วยเพื่อให้วิเคราะห์ percentile ตาม segment ได้:
{
"url": "/products/1234",
"route": "/products/[id]", // route template สำหรับ group
"metrics": [
{ "name": "LCP", "value": 2341, "rating": "good", "id": "v5-1234" },
{ "name": "INP", "value": 187, "rating": "good", "id": "v5-1235" }
],
"device": {
"type": "mobile", // จาก UA-CH หรือ UA parsing
"memory": 4, // navigator.deviceMemory (GB)
"cores": 4 // navigator.hardwareConcurrency
},
"network": {
"effectiveType": "4g", // navigator.connection.effectiveType
"downlink": 8.2, // Mbps
"rtt": 100 // ms
},
"navigationType": "navigate", // navigate | reload | back_forward | prerender
"sessionId": "u-abc123", // ไม่ระบุตัวตน สำหรับ dedupe เท่านั้น
"timestamp": 1720089600000
}
เคล็ดลับ: ใช้ route template (เช่น /products/[id]) แทน URL ดิบ เพื่อ group page ที่มี pattern เดียวกัน ไม่งั้น dashboard จะมี unique URL หลายล้านแถวและหา p75 ไม่ได้
ใช้ Attribution Build เพื่อหาต้นเหตุ INP และ LCP
web-vitals.js v5 มี build แยกชื่อ web-vitals/attribution ที่แนบข้อมูลวินิจฉัย element และ script ที่เป็นต้นเหตุมาให้ในทุก metric ตัวอย่างของ INP attribution จะบอกว่า interaction อะไรทำให้ค่าแย่ (click ปุ่ม X), event target selector, และ script ที่ block main thread ในระหว่าง presentation delay
import { onINP, onLCP } from 'web-vitals/attribution';
onINP((metric) => {
const attr = metric.attribution;
console.log('INP:', metric.value, 'ms');
console.log('interaction:', attr.interactionType); // pointer | keyboard
console.log('target:', attr.interactionTarget); // CSS selector
console.log('input delay:', attr.inputDelay, 'ms');
console.log('processing:', attr.processingDuration, 'ms');
console.log('presentation delay:', attr.presentationDelay, 'ms');
console.log('long tasks:', attr.longAnimationFrameEntries);
});
onLCP((metric) => {
const attr = metric.attribution;
console.log('LCP element:', attr.element); // CSS selector
console.log('url:', attr.url); // resource URL
console.log('TTFB:', attr.timeToFirstByte, 'ms');
console.log('resource load delay:', attr.resourceLoadDelay, 'ms');
console.log('resource load time:', attr.resourceLoadDuration, 'ms');
console.log('element render delay:', attr.elementRenderDelay, 'ms');
});
ข้อมูล 4 subparts ของ LCP (TTFB, resource load delay, resource load duration, element render delay) ทำให้ระบุปัญหาได้ทันที เช่น ถ้า TTFB สูงคุณต้องแก้ที่ server ถ้า resource load delay สูงแสดงว่า preload ผิดหรือ discovery ช้า ให้ใช้ fetchpriority="high" ตามที่ระบุในคู่มือเพิ่มประสิทธิภาพรูปภาพด้วย AVIF, WebP และ fetchpriority
บันทึก attribution ในระบบวิเคราะห์
เมื่อส่ง attribution ไปยัง endpoint ให้ระวังขนาด payload ตัด long strings อย่าง selector ให้เหลือ 100 ตัวอักษร และตัด longAnimationFrameEntries เหลือ 3 entries แรก ไม่งั้น payload จะเกิน 64 KB limit ของ sendBeacon โดยเฉพาะเวลาที่มี long task หลายสิบตัว
แยก percentile ตามอุปกรณ์และการเชื่อมต่อ
การรายงาน p75 รวมทุก device คือความผิดพลาดใหญ่ที่สุดของ dashboard RUM ที่ผมเคยเห็น เพราะ desktop กลบ mobile และ Wi-Fi กลบ mobile network สูตรที่ผมใช้คือแยกอย่างน้อยตาม 2 มิติ: device class (mobile/tablet/desktop) และ effective connection type (4g/3g/2g/slow-2g)
สำหรับการคำนวณ p75 ให้ใช้ streaming quantile algorithm เช่น t-digest หรือ HDR histogram ในฝั่ง backend ไม่ใช่เก็บทุก data point แล้วมา sort ทุกครั้ง สำหรับ prototype ที่ traffic ต่ำ (< 10k RUM points ต่อวัน) การใช้ SQL PERCENTILE_CONT(0.75) ยังพอไหว แต่พอเกิน 100k ต้องเปลี่ยนเป็น approximation algorithm
ตารางเปรียบเทียบเกณฑ์ Core Web Vitals ปี 2026
Metric Good (p75) Needs Improvement (p75) Poor (p75)
LCP ≤ 2.5s 2.5s – 4.0s > 4.0s
INP ≤ 200ms 200ms – 500ms > 500ms
CLS ≤ 0.1 0.1 – 0.25 > 0.25
TTFB ≤ 800ms 800ms – 1800ms > 1800ms
FCP ≤ 1.8s 1.8s – 3.0s > 3.0s
Google ประกาศเกณฑ์เหล่านี้ตั้งแต่มีนาคม 2024 และยังไม่เปลี่ยนในปี 2026 อย่างไรก็ตามการรายงานให้ทีมพัฒนาควรใช้ p75 แยก mobile และ desktop เพราะการที่ค่ารวมผ่านไม่ได้แปลว่า mobile ผ่าน
หมายเหตุ: Google ใช้ p75 ของทั้ง mobile และ desktop แยกกันในการจัด "passing Core Web Vitals" สำหรับ Search ranking factor ถ้า mobile p75 ไม่ผ่านแต่ desktop ผ่าน ยังถือว่าไม่ผ่านเมื่อค้นหาจาก mobile device
ส่งข้อมูลไปยัง Google Analytics 4
ถ้าคุณไม่มีทรัพยากรทำ analytics endpoint เอง GA4 รับ custom event ได้ผ่าน Measurement Protocol และ SDK ในเบราว์เซอร์ web-vitals v5 ทำงานร่วมกับ GA4 event schema ได้ตรงๆ:
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';
function sendToGA4({ name, delta, value, id, rating }) {
// gtag() คือฟังก์ชันจาก GA4 tag
gtag('event', name, {
value: Math.round(name === 'CLS' ? delta * 1000 : delta),
metric_id: id,
metric_value: value,
metric_rating: rating,
metric_delta: delta,
// custom dimensions สำหรับ segment
device_memory: navigator.deviceMemory,
effective_connection: navigator.connection?.effectiveType,
non_interaction: true,
});
}
onLCP(sendToGA4);
onINP(sendToGA4);
onCLS(sendToGA4);
onTTFB(sendToGA4);
onFCP(sendToGA4);
ข้อจำกัดของ GA4 คือ 1) sampling ที่ระดับ report ทำให้ p75 ที่แสดงไม่แม่นสำหรับ traffic สูง 2) ไม่รองรับ p75 native ต้องใช้ Looker Studio + BigQuery export เพื่อคำนวณเอง 3) event limit 500 events ต่อ session อาจถึงเร็วถ้าผู้ใช้อยู่หน้านานและมี INP หลายค่า สำหรับไซต์ที่จริงจังกับ RUM ผมแนะนำ endpoint ของตัวเองหรือใช้ service เฉพาะทางอย่าง SpeedCurve, Sentry Performance
เปรียบเทียบ RUM ของคุณเองกับ CrUX API
Chrome UX Report (CrUX) คือชุดข้อมูล field data ที่ Google เก็บจาก Chrome users ที่ opt-in เข้า sync history ครอบคลุมกว่า 15 ล้าน origin ทั่วโลก มีทั้ง CrUX API แบบ real-time (28-day rolling) และ BigQuery dataset รายเดือน
ตัวอย่างการเรียก CrUX API สำหรับ origin ของคุณ:
const CRUX_API_KEY = 'YOUR_API_KEY';
async function getFieldData(origin) {
const res = await fetch(
`https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${CRUX_API_KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
origin,
formFactor: 'PHONE', // PHONE | DESKTOP | TABLET
metrics: [
'largest_contentful_paint',
'interaction_to_next_paint',
'cumulative_layout_shift',
'experimental_time_to_first_byte',
],
}),
}
);
return res.json();
}
const data = await getFieldData('https://example.com');
console.log(data.record.metrics.largest_contentful_paint.percentiles.p75);
ข้อแตกต่างระหว่าง RUM ของคุณเองกับ CrUX
CrUX มี sample bias สำคัญ 3 อย่างที่ต้องระวัง: 1) เฉพาะ Chrome users, 2) เฉพาะ opt-in sync, 3) เฉพาะ page ที่ได้รับ traffic เกิน threshold (ประมาณ 1000 visitors ต่อ 28 วัน) เว็บใหม่หรือ page ปลายซอยจะไม่ปรากฏใน CrUX เลย
RUM ของคุณเองให้ข้อมูล page-level ทุกหน้า, real-time (< 5 นาที), และรวม browser อื่นด้วย เหมาะสำหรับการวินิจฉัยและ alert แต่ CrUX คือแหล่งเดียวที่ Google ใช้จริงในการจัดอันดับ Search ดังนั้นแนวปฏิบัติที่ถูกต้องคือใช้ทั้งคู่: RUM ตัวเองเพื่อ debug และ CrUX เพื่อยืนยันสิ่งที่ Google เห็น
กลยุทธ์ sampling และ cost control
ที่ traffic ระดับ 1 ล้าน pageview ต่อวัน การส่ง RUM ทุก session จะสร้าง cost analytics มหาศาลและ payload บน CDN egress ที่ไม่จำเป็น กลยุทธ์ที่ผมใช้คือ deterministic sampling ตาม hash ของ session id เพื่อให้ session เดียวกันเห็นทุก metric ครบ:
const SAMPLE_RATE = 0.1; // 10%
function shouldSample() {
// ใช้ sessionStorage เพื่อความคงที่ต่อ session
let sampled = sessionStorage.getItem('rum-sampled');
if (sampled === null) {
sampled = Math.random() < SAMPLE_RATE ? '1' : '0';
sessionStorage.setItem('rum-sampled', sampled);
}
return sampled === '1';
}
if (shouldSample()) {
// register web-vitals callbacks เฉพาะเมื่อ sampled
import('./rum.js');
}
Sample rate ที่ 10% เพียงพอสำหรับ traffic เกิน 100k pageview/วัน เพื่อได้ p75 ที่มี confidence interval ± 5% ถ้า traffic ต่ำกว่า 10k ให้ใช้ 100% sampling ถ้าเกิน 10 ล้าน pageview/วัน สามารถลดลงเหลือ 1% ได้
เก็บเฉพาะ metric ที่แย่ (outlier sampling)
อีกกลยุทธ์คือ tail sampling: เก็บทุก session ที่มี metric อย่างน้อยหนึ่งตัวเป็น "poor" และ sample 5% ของ session ปกติ วิธีนี้ทำให้เห็น outlier ครบและประหยัด storage 90%+ แต่ต้องระวังไม่ให้ p75 bias เพราะ dataset จะเอียงไปทาง outlier ทางแก้คือถ่วงน้ำหนักย้อนกลับตอนคำนวณ percentile
เคล็ดลับ: สำหรับไซต์ที่มีทั้ง SPA และ MPA รูปแบบผสม ให้เก็บ metric ต่อ soft navigation แยกด้วย navigation id ของ web-vitals v5 รองรับ soft navigations native ตั้งแต่เวอร์ชัน 4.2 ทำให้ SPA route change วัดเหมือน hard navigation
สำหรับผู้ที่ต้องการต่อยอดไปสู่การเร่งความเร็วในระดับ preload/prerender ให้ดูคู่มือ Speculation Rules API สำหรับ prerender และ prefetch ประกอบ เพราะ metric ในเซสชัน prerender จะถูกแบ่งประเภท navigationType: 'prerender' โดยอัตโนมัติ
คำถามที่พบบ่อย
RUM ต่างจาก Lighthouse อย่างไร?
Lighthouse เป็น synthetic testing ที่รันในเครื่องทดสอบภายใต้เงื่อนไข emulated (CPU 4x throttle, Slow 4G) ในขณะที่ RUM เก็บข้อมูลจากผู้ใช้จริงในสภาพเครือข่ายและอุปกรณ์จริง Google ใช้ CrUX field data (คล้าย RUM) เป็น Search ranking factor ไม่ใช่ Lighthouse score
web-vitals.js ทำงานกับ React, Vue, Next.js ได้ไหม?
ได้ เพราะ web-vitals.js ใช้ browser Performance Observer API ล้วนๆ ไม่ผูกกับ framework Next.js 15 มี built-in support ผ่าน useReportWebVitals hook ส่วน React, Vue, Svelte เรียก callback ใน root component ได้เลย
ต้อง sample rate เท่าไหร่ถึงจะเห็น p75 ที่แม่นยำ?
สำหรับ p75 ที่มี margin of error ±5% ต้องมี data point อย่างน้อย ~600 ต่อ segment ต่อวัน ถ้าคุณต้องการวิเคราะห์แยก 4 device types × 4 connection types = 16 segment ต้องมี 9,600 data point ต่อวันขั้นต่ำ กำหนด sample rate ตาม traffic จริง
ใช้ RUM ทดแทน CrUX ได้เลยไหม?
ทดแทนไม่ได้ เพราะ Google Search ใช้ CrUX เป็น ranking signal ไม่ใช่ RUM ของคุณ แต่ RUM ให้ข้อมูล real-time และ page-level ที่ CrUX ไม่มี ใช้คู่กัน: RUM สำหรับ debug และ alert, CrUX สำหรับยืนยันสิ่งที่ Google เห็น
RUM ทำให้เว็บช้าลงหรือไม่?
web-vitals.js v5 เพิ่มขนาด ~2 KB gzip และใช้ Performance Observer ที่ passive เป็นหลัก ผลกระทบต่อ INP น้อยกว่า 1ms ในเครื่องระดับกลาง โหลดแบบ async หลัง critical resource เพื่อให้ไม่แย่งเวลากับ LCP