Real User Monitoring med web-vitals.js i 2026: Sådan indsamler du Core Web Vitals fra rigtige brugere

Komplet 2026-guide til Real User Monitoring med web-vitals.js v4: attribution, sendBeacon, p75-segmentering på enhed og netværk, backend-endpoint og alarmering. Færdige kodeeksempler til Node.js og ClickHouse.

web-vitals.js RUM Guide 2026

Opdateret: 6. juli 2026

Real User Monitoring (RUM) med web-vitals.js er den eneste pålidelige måde at måle Core Web Vitals på i 2026. Du inkluderer Googles officielle web-vitals JavaScript-bibliotek på siden, lytter på LCP, INP og CLS via PerformanceObserver, og sender målingerne til dit eget analyseendpoint med navigator.sendBeacon(). Modsat Lighthouse-audits, der kører én gang på én maskine, indsamler RUM p75-værdier fra tusindvis af rigtige brugere på forskellige enheder, netværk og geografier. Det er præcis den samme metode, Google selv bruger til at bygge CrUX-datasættet.

  • web-vitals.js v4 rapporterer LCP, INP og CLS direkte i browseren og understøtter attribution, der peger på den præcise DOM-node eller event-handler, som forårsagede den dårlige score.
  • Brug altid navigator.sendBeacon() (ikke fetch()) for at sikre, at målinger når frem, selv når brugeren navigerer væk.
  • Rapportér p75-værdier, ikke gennemsnit. Google bedømmer efter 75. percentil pr. metric pr. 28-dages CrUX-vindue.
  • Segmentér altid på enhedsklasse (mobil vs. desktop) og netværkstype (4G vs. 5G vs. WiFi). Aggregerede tal skjuler de virkelige problemer.
  • Lav feltdata-baseret alarmering på p75-INP, ikke på Lighthouse-scores. INP-regressioner ses ofte kun i produktion.
  • web-vitals v4 emitterer nu opdaterede LCP- og CLS-værdier løbende, ikke kun ved page unload. Det forbedrer datakvaliteten markant for SPA'er.

Hvad er Real User Monitoring?

Real User Monitoring (RUM) er praksissen med at måle performance på din faktiske produktionsside, i den faktiske browser, hos den faktiske bruger. I stedet for at simulere en session på en emuleret Moto G4 i et datacenter i Iowa, sætter du et lille stykke JavaScript ind på siden, som lytter på browserens indbyggede performance-API'er og sender målingerne tilbage til dig.

Ærligt talt, jeg har brugt de sidste otte år på at overbevise teams om, at deres grønne Lighthouse-scores lyver. Det gør de. Lighthouse kører fra én geografi, over ét netværk, én gang, med tom cache. Din faktiske brugerbase kører fra 40 lande, over 3G, 4G, 5G og WiFi, på enheder fra 2018 til i år, med og uden cache, med og uden reklameblokkere, og ofte med en battery-saver, der throttler CPU'en 4x. Kun feltdata fanger den virkelighed.

Google er selv enige. Core Web Vitals-thresholds i Search Console er baseret på CrUX (Chrome User Experience Report), som er RUM-data indsamlet fra opt-in Chrome-brugere. Hvis du vil vide, hvad Google ser, når de rangerer din side, skal du selv indsamle det samme.

RUM vs. syntetisk monitoring: sammenligning

Både RUM og syntetiske tests har deres plads, men de svarer på fundamentalt forskellige spørgsmål. Her er, hvordan de er stakket op mod hinanden.

EgenskabRUM (feltdata)Syntetisk (Lighthouse)
DatakildeRigtige brugere i produktionEmulerede sessioner
INP-målingJa, kræver rigtige klikNej, ingen brugerinteraktion
EnhedsvariationHele porteføljenÉn fast profil (fx Moto G4)
NetværksvariationÆgte 3G/4G/5G/WiFiSimuleret throttling
Repræsenterer CrUXJaNej
Egner sig til CINejJa
Fanger regressioner præ-releaseNejJa
Fanger produktionsregressionerJaSjældent

Det korte svar: kør Lighthouse i CI for at fange åbenlyse regressioner før release, og kør RUM i produktion for at måle sandheden. Har du kun budget til én af dem? Vælg RUM. Lighthouse fanger måske 30% af de problemer, dine brugere faktisk oplever.

For en dybere gennemgang af, hvordan INP måles i felten kontra syntetisk, har jeg skrevet om Long Animation Frames API og INP-fejlfinding. LoAF er den anden halvdel af RUM-historien, når du skal forstå, hvorfor INP er dårlig.

web-vitals.js v4: hvad er nyt i 2026

Google Chrome-teamets web-vitals JavaScript-bibliotek er de-facto RUM-klienten. Version 4 landede i 2024 og har fået flere små inkrementelle opdateringer siden. De vigtigste ændringer, du skal kende i 2026:

  • Løbende opdatering af LCP og CLS: v4 kalder din callback flere gange, når værdien ændrer sig, ikke kun ved page-unload. Det betyder, at SPA'er, der aldrig unloader, endelig får korrekte tal.
  • Attribution-build: web-vitals/attribution er nu det anbefalede import. Det inkluderer attribution-objektet med præcise pointers til DOM-noder, event-typer og RTT-værdier.
  • INP med Long Animation Frames: attribution for INP inkluderer nu et longAnimationFrameEntries-array, der peger på den specifikke script-eksekvering, som blokerede main thread.
  • Ingen FID: First Input Delay blev formelt fjernet 12. september 2024. Hvis din monitoring stadig lytter på FID, indsamler du ingenting.
  • navigation-type: hver rapport inkluderer nu navigationType (navigate, reload, back-forward, back-forward-cache, eller prerender). Segmentér altid dine tal på denne.

bfcache-navigationer scorer typisk perfekt på alle Core Web Vitals, mens prerender-navigationer scorer bedre end almindelige. Hvis du blander disse i én bucket, ser din p75 bedre ud, end den er. Læs mere om, hvordan bfcache påvirker målinger, i min bfcache-guide fra 2026.

Installation og basal opsætning

Installér biblioteket via npm:

npm install web-vitals

Eller inkludér det direkte fra en CDN, hvis du vil undgå bundleren:

<script type="module">
  import { onLCP, onINP, onCLS } from 'https://unpkg.com/web-vitals@4?module';
  onLCP(console.log);
  onINP(console.log);
  onCLS(console.log);
</script>

Størrelsen er cirka 2 KB gzippet for kernefunktionerne. Attribution-builden er omkring 6 KB. Læg det inline i <head> med defer, eller endnu bedre: inline det som et modul i selve HTML-svaret. Biblioteket skal indlæses tidligt, ellers misser du de første målinger.

// rum.js: indlæs så tidligt som muligt
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';

function report(metric) {
  // metric = { name, value, rating, delta, id, navigationType, ... }
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
    url: location.pathname,
    ts: Date.now(),
  });

  // Foretræk sendBeacon; fald tilbage til fetch keepalive
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/rum', body);
  } else {
    fetch('/rum', { body, method: 'POST', keepalive: true });
  }
}

onLCP(report);
onINP(report);
onCLS(report);
onTTFB(report);
onFCP(report);

Bemærk, at onINP først rapporterer efter den første brugerinteraktion. Hvis brugeren aldrig klikker eller trykker, får du ingen INP-værdi. Det er korrekt opførsel: sider uden interaktion har ingen INP.

Attribution: find den præcise årsag

Det her er stedet, hvor RUM løber fra syntetisk monitoring med flere hestehalslængder. Attribution-builden fortæller dig hvorfor en måling var dårlig: hvilket element, hvilken event-handler, hvilket script.

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

onINP((metric) => {
  const attr = metric.attribution;
  console.log({
    inp: metric.value,
    interactionTarget: attr.interactionTarget,  // fx 'button.checkout-btn'
    interactionType: attr.interactionType,      // 'pointer' | 'keyboard'
    inputDelay: attr.inputDelay,                // tid før handler kørte
    processingDuration: attr.processingDuration,// tid handler brugte
    presentationDelay: attr.presentationDelay,  // tid før næste paint
    longAnimationFrameEntries: attr.longAnimationFrameEntries,
    loadState: attr.loadState,
  });

  navigator.sendBeacon('/rum/inp', JSON.stringify({
    value: metric.value,
    target: attr.interactionTarget,
    type: attr.interactionType,
    inputDelay: attr.inputDelay,
    processingDuration: attr.processingDuration,
    presentationDelay: attr.presentationDelay,
    scriptSource: attr.longAnimationFrameEntries?.[0]?.scripts?.[0]?.sourceURL,
  }));
});

Når du får en INP-alarm i produktion, kan du direkte se: "83% af de dårlige INP-events kommer fra button.newsletter-signup, og processingDuration er 340 ms. Main thread er blokeret af analytics.min.js." Det tager fem minutter at diagnosticere. Uden attribution ville det tage tre dage. (Jeg har prøvet begge dele. Den ene er meget sjovere end den anden.)

For LCP giver attribution dig element, url (billed-URL'en), timeToFirstByte, resourceLoadDelay, resourceLoadDuration, og elementRenderDelay, så du ved præcist, hvor i LCP-fasen du taber tid. Kombinér det med teknikkerne i min guide til LCP-optimering i 2026.

Send data med sendBeacon

Den mest oversete detalje i RUM-implementeringer: hvordan du sender data. Almindelig fetch() dør, når brugeren navigerer væk fra siden. Og page-unload er præcis det tidspunkt, hvor mange metrics finaliseres. Løsningen er navigator.sendBeacon(), som browseren garanterer bliver sendt, selv efter unload.

function report(metric) {
  const body = JSON.stringify({ ...metric, url: location.pathname });
  const url = '/rum';

  // sendBeacon er "fire and forget"; returnerer true hvis kø'et
  const ok = navigator.sendBeacon(url, body);
  if (!ok) {
    // Fald tilbage til fetch med keepalive-flag
    fetch(url, {
      method: 'POST',
      body,
      keepalive: true,
      headers: { 'Content-Type': 'application/json' },
    }).catch(() => {});
  }
}

Vigtigt: sendBeacon har en 64 KB payload-grænse pr. kald. Hvis du samler mange metrics i én besked, så batch dem. Men send hver metric individuelt, hvis batchen risikerer at komme over grænsen. Beacon-kald, der overskrider grænsen, returnerer false uden at sende noget.

Backend-endpoint og datalagring

Endpointet behøver ikke være kompliceret. Her er en minimal Node.js-implementation med Express, der skriver til ClickHouse (min foretrukne til RUM på grund af hastigheden på p75-aggregation):

import express from 'express';
import { createClient } from '@clickhouse/client';

const app = express();
const ch = createClient({ url: 'http://clickhouse:8123' });

app.post('/rum', express.json({ limit: '64kb' }), async (req, res) => {
  const m = req.body;
  const ua = req.headers['user-agent'] || '';
  const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.ip;

  await ch.insert({
    table: 'rum_metrics',
    values: [{
      ts: new Date(),
      metric: m.name,
      value: m.value,
      rating: m.rating,
      url_path: m.url,
      nav_type: m.navigationType,
      country: req.headers['cf-ipcountry'] || null,
      device_class: classify(ua),
      // Hash IP for dataminimering; gem aldrig rå IP
      session_hash: hash(ip + ua),
    }],
    format: 'JSONEachRow',
  });

  res.status(204).end();
});

ClickHouse er cirka 1000x hurtigere end Postgres til den type spørgsmål, du kommer til at stille, fx "hvad er p75-INP på tværs af de sidste 28 dage grupperet på url_path og device_class?". Hvis du ikke vil hoste selv, giver Grafana Cloud, BigQuery, eller Datadog RUM alle brugbare backends.

Undgå at gemme rå IP-adresser. Hash dem sammen med user-agent for at få en pseudo-session-identifier, uden at bryde GDPR. Log ikke query strings, der kunne indeholde tokens eller e-mails.

Segmentering efter enhed og netværk

Aggregerede tal er værre end ingen tal. De giver dig falsk tryghed. Din desktop-p75-INP på 120 ms ser fin ud, indtil du splitter den, og finder ud af, at mobile-p75-INP er 480 ms.

Klassificér altid enheden ved indsamlingstidspunktet. Brug navigator.userAgentData, hvis det er tilgængeligt, ellers fald tilbage til navigator.deviceMemory og navigator.hardwareConcurrency:

function classifyDevice() {
  const uaData = navigator.userAgentData;
  const mem = navigator.deviceMemory || 4;
  const cores = navigator.hardwareConcurrency || 4;

  const mobile = uaData?.mobile ?? /Mobile|Android/.test(navigator.userAgent);
  if (mobile && (mem < 4 || cores < 4)) return 'low-end-mobile';
  if (mobile) return 'high-end-mobile';
  if (mem < 4 || cores < 4) return 'low-end-desktop';
  return 'high-end-desktop';
}

function classifyNetwork() {
  const c = navigator.connection;
  if (!c) return 'unknown';
  return c.effectiveType; // 'slow-2g' | '2g' | '3g' | '4g'
}

Send begge klassifikationer med hver metric. På backend'en har du så mulighed for at trække p75 pr. kombineret klasse:

-- ClickHouse: p75 INP pr. device_class x network for sidste 7 dage
SELECT
  device_class,
  network_type,
  quantile(0.75)(value) AS p75_inp,
  count() AS samples
FROM rum_metrics
WHERE metric = 'INP'
  AND ts > now() - INTERVAL 7 DAY
GROUP BY device_class, network_type
HAVING samples > 100
ORDER BY p75_inp DESC;

Første gang du kører den her query, får du sandsynligvis et lille chok. Low-end-mobile på 3G ligger typisk 3-5x højere end high-end-desktop på WiFi. Det er dér, dine optimeringer skal målrettes.

Sådan sender du Core Web Vitals til Google Analytics 4

Hvis du allerede har GA4 installeret, kan du sende Core Web Vitals som custom events uden at bygge et separat endpoint. Det er den nemmeste start-strategi:

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

function sendToGA4(metric) {
  gtag('event', metric.name, {
    value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
    metric_id: metric.id,
    metric_value: metric.value,
    metric_delta: metric.delta,
    metric_rating: metric.rating,
    debug_target: metric.attribution?.interactionTarget
      || metric.attribution?.element
      || metric.attribution?.largestShiftTarget,
  });
}

onLCP(sendToGA4);
onINP(sendToGA4);
onCLS(sendToGA4);

Byg derefter en Looker Studio-rapport, der plotter p75 pr. metric pr. side. GA4's percentile-funktioner er begrænsede, så du får bedst opløsning ved at eksportere til BigQuery og køre APPROX_QUANTILES selv.

Alarmering på p75-regressioner

Feltdata er kun værdifuldt, hvis nogen ser på det, når det bliver værre. Byg alarmer baseret på p75 (ikke gennemsnit, ikke p99). Googles Core Web Vitals-thresholds er defineret på p75, så det er den, du bliver bedømt på.

En brugbar alarmregel: p75-INP over 200 ms i en 6-timers periode, med minimum 500 samples pr. url-pattern. Nedenstående er et Grafana alert-eksempel:

SELECT
  toStartOfHour(ts) AS bucket,
  quantile(0.75)(value) AS p75
FROM rum_metrics
WHERE metric = 'INP'
  AND url_path LIKE '/checkout%'
  AND ts > now() - INTERVAL 6 HOUR
GROUP BY bucket
HAVING count() > 500;
-- Alert hvis nogen bucket > 200

Bemærk minimum-sample-kravet. Uden det får du støjalarmer fra buckets med kun 10-20 samples, hvor p75 kan hoppe voldsomt. Jeg anbefaler minimum 500 samples for INP, 200 for LCP, 200 for CLS.

Almindelige faldgruber ved RUM-implementering

Efter otte år med RUM-opsætninger i produktion, er det her de fejl, jeg ser gentaget flest gange:

  • Kun måler LCP: du misser 60% af oplevelsen. INP er lige så vigtig og fanger reaktivitetsproblemer, som Lighthouse aldrig ser. Læs mere i min guide til INP-optimering i 2026.
  • Bruger fetch() uden keepalive: 30-40% pakketab ved unload. Brug altid sendBeacon eller keepalive-flaget.
  • Aggregerer på tværs af navigationstyper: bfcache-hits scorer perfekt og trækker snittet ned. Segmentér altid på navigationType.
  • Rapporterer gennemsnit, ikke p75: 100 hurtige besøg + 5 elendige giver et snit, der ser fint ud. Google ser p75.
  • Filtrerer ikke bots: bot-trafik forvrider dine tal. Headless Chrome scorer typisk 10x bedre end rigtige brugere. Filtrér på navigator.webdriver og user-agent-strings.
  • Måler kun ét dashboard: byg drilldowns pr. url-pattern. En regression på /checkout kan drukne i det aggregerede tal for hele sitet.
  • Deployer RUM i CI som "test": RUM måler ikke i CI. Det måler i produktion. Det er hele pointen.

Ofte stillede spørgsmål

Hvad er forskellen mellem RUM og syntetisk performance-testing?

RUM måler faktiske brugere i produktion, syntetisk testing simulerer én session i et kontrolleret miljø. RUM er den eneste kilde til rigtig INP-data, fordi INP kræver ægte brugerinteraktioner. Syntetisk er stadig nyttig i CI for at fange åbenlyse regressioner før release.

Hvor meget påvirker web-vitals.js selv min sides performance?

Kernebiblioteket er ca. 2 KB gzippet og bruger passive event-listeners plus PerformanceObserver, som begge er non-blocking. På moderne enheder er overhead under 1 ms. Attribution-builden er ~6 KB og har samme non-blocking karakteristik.

Kan jeg bruge CrUX-data i stedet for at opsætte min egen RUM?

Delvist. CrUX giver dig 28-dages p75-aggregater pr. origin eller pr. URL, men kun for de mest besøgte sider og kun for Chrome-brugere med opt-in. CrUX kan ikke svare på "hvorfor blev INP dårlig i går?" eller "hvordan performer /checkout på low-end Android?". Egen RUM giver dig realtid og attribution.

Hvorfor får jeg ingen INP-værdier fra nogle brugere?

Fordi de aldrig interagerede med siden. INP måles på brugerinput, så en session, der scroller igennem indhold og forlader, vil have LCP og CLS, men ingen INP. Det er korrekt opførsel. Segmentér altid dine INP-tal på "kun sessioner med interaktion" for at få meningsfuld data.

Er First Input Delay (FID) stadig relevant i 2026?

Nej. FID blev formelt fjernet fra Core Web Vitals 12. september 2024 og erstattet af INP. Hvis din monitoring-opsætning stadig lytter på FID via web-vitals-biblioteket, indsamler du ingenting; kaldet er en no-op i v4. Skift til onINP.

Hvor længe skal jeg gemme mine RUM-data?

Minimum 28 dage, fordi det er CrUX-vinduet, du bliver bedømt på. Jeg opbevarer typisk 90 dages rå data plus 2 års aggregerede daglige p75-værdier pr. url-pattern og enhedsklasse. Det giver dig plads til at spore langtidstrends, uden at storage-omkostningerne eksploderer.

Nadia El-Sayed
Om Forfatteren Nadia El-Sayed

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