ติดตั้ง RUM ด้วย web-vitals.js ปี 2026: คู่มือเก็บ Core Web Vitals จากผู้ใช้จริง

คู่มือครบวงจรติดตั้ง Real User Monitoring ด้วย web-vitals.js v5 พร้อมการส่งข้อมูลด้วย sendBeacon, Attribution Build, การแยก percentile ตามอุปกรณ์ และการเปรียบเทียบกับ CrUX API

ติดตั้ง RUM ด้วย web-vitals.js (2026)

อัปเดตล่าสุด: 4 กรกฎาคม 2026

Real User Monitoring (RUM) ด้วย web-vitals.js คือการเก็บค่า Core Web Vitals จากเบราว์เซอร์ของผู้ใช้จริงแทนการรันเครื่องมือทดสอบในห้องแล็บ โดยใช้ไลบรารีขนาดเล็ก (~2 KB) จาก Google Chrome team เพื่อวัด LCP, INP, CLS, TTFB และ FCP แล้วส่งข้อมูลไปยัง analytics endpoint ของคุณ พูดกันตามตรงนะครับ ตลอดปีที่ผ่านมาผมได้ติดตั้ง RUM บนไซต์ที่มี traffic หลักล้าน pageview ต่อเดือน (ทั้งอีคอมเมิร์ซและสื่อ) และพบว่าตัวเลขจาก Lighthouse บน desktop ไม่เคยตรงกับความจริงที่ผู้ใช้มือถือระดับ p75 เจอเลย ก็เลยเขียนบทความนี้เพื่อพาคุณติดตั้ง RUM ตั้งแต่ศูนย์จนถึงการวิเคราะห์ percentile ตามอุปกรณ์ครับ

  • 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 อย่างไร

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);

ส่งข้อมูลด้วย 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
}

ใช้ 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

MetricGood (p75)Needs Improvement (p75)Poor (p75)
LCP≤ 2.5s2.5s – 4.0s> 4.0s
INP≤ 200ms200ms – 500ms> 500ms
CLS≤ 0.10.1 – 0.25> 0.25
TTFB≤ 800ms800ms – 1800ms> 1800ms
FCP≤ 1.8s1.8s – 3.0s> 3.0s

Google ประกาศเกณฑ์เหล่านี้ตั้งแต่มีนาคม 2024 และยังไม่เปลี่ยนในปี 2026 อย่างไรก็ตามการรายงานให้ทีมพัฒนาควรใช้ p75 แยก mobile และ desktop เพราะการที่ค่ารวมผ่านไม่ได้แปลว่า mobile ผ่าน

ส่งข้อมูลไปยัง 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

สำหรับผู้ที่ต้องการต่อยอดไปสู่การเร่งความเร็วในระดับ 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

Nadia El-Sayed
เกี่ยวกับผู้เขียน Nadia El-Sayed

Core Web Vitals specialist focused on real-user monitoring. Believes synthetic-only perf testing is a comforting lie.