PerformanceObserver API: Come Costruire un RUM Custom per Core Web Vitals nel 2026
Come costruire un RUM custom con PerformanceObserver per misurare LCP, INP e CLS sugli utenti reali: codice pratico, sendBeacon e attribuzione con LoAF.
La PerformanceObserver API è l'interfaccia nativa del browser per osservare eventi di performance (LCP, INP, CLS, Long Animation Frames, Resource Timing) mentre accadono, senza polling e con zero overhead sul main thread. Costruire un RUM (Real User Monitoring) custom sopra PerformanceObserver ti permette di misurare i Core Web Vitals sui tuoi utenti reali, correlarli a route, dispositivo e A/B test, e spedirli al tuo backend con navigator.sendBeacon(), senza dipendere da vendor esterni che aggiungono 30–80 KB di script sincrono al bundle.
PerformanceObserver con buffered: true è l'unico modo affidabile per catturare LCP e CLS che si verificano prima che il tuo script RUM esegua.
Per l'INP nel campo devi osservare event e first-input, poi calcolare il 98° percentile delle interazioni per sessione. Non basta prendere il massimo.
navigator.sendBeacon() è l'unico modo garantito di spedire dati durante visibilitychange → hidden, che è il vero "end of session" su mobile.
Il flag durationThreshold: 40 su PerformanceObserver per gli event entries riduce drasticamente il volume di beacon senza perdere i dati che contano per l'INP.
Un RUM custom ben scritto pesa 2–3 KB gzipped e non blocca il main thread, ossia meno di 1/10 di qualsiasi soluzione vendor.
Correla sempre le metriche con navigator.connection.effectiveType, deviceMemory e il tuo build ID per rendere i dati azionabili.
Cos'è PerformanceObserver e perché non usare performance.getEntries()
Ho visto team spedire in produzione script RUM che chiamano performance.getEntriesByType('largest-contentful-paint') dentro un setInterval(500). Funziona, fino a quando il buffer del browser si riempie (di default 150 entries) e alcune LCP candidate vengono droppate silenziosamente. PerformanceObserver risolve entrambi i problemi: riceve gli entries via callback nel momento in cui il browser li registra, e supporta il flag buffered: true che gli fa consegnare tutti gli entries già presenti nel buffer al momento della registrazione.
La differenza pratica è enorme. Con getEntries() stai facendo polling contro uno stato mutabile che il browser può troncare; con PerformanceObserver({ buffered: true }) hai la garanzia di ricevere anche quello che è successo prima che il tuo script fosse parseato. Su mobile 4G lento, dove il RUM script può arrivare 800 ms dopo la LCP, questa distinzione fa la differenza tra un dato reale e un P75 gonfiato del 40%.
// SBAGLIATO: polling, race con il buffer del browser
setInterval(() => {
const entries = performance.getEntriesByType('largest-contentful-paint');
// ...
}, 500);
// GIUSTO: PerformanceObserver con buffered flag
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// ricevi ogni LCP candidate appena viene registrata,
// inclusi quelli già nel buffer quando registri l'observer
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
Entry types supportati per il RUM nel 2026
Il set di entry types utili per Core Web Vitals nel 2026 è ormai consolidato. Ecco quelli che uso sempre in un RUM di produzione, con le versioni di Chrome dove sono diventati stabili:
Entry type
Metrica coperta
Supporto
Note
largest-contentful-paint
LCP
Chrome 77+, Safari 17+
Ogni LCP candidate genera un entry; usa l'ultimo
event
INP
Chrome 96+, Safari 18.2+
Richiede durationThreshold per volume gestibile
first-input
FID legacy / INP
Universale
Fallback per browser senza event
layout-shift
CLS
Chrome 77+, Safari 17+
Devi implementare tu la session window
long-animation-frame
Debug INP
Chrome 123+
Attribuisce blocchi del main thread agli script sorgente
navigation
TTFB, DOM ready
Universale
Un solo entry per pagina
resource
Timing risorse
Universale
Utile per correlare LCP a preload/priority
paint
FCP
Universale
Include first-paint e first-contentful-paint
Per un RUM custom minimale copro LCP, INP, CLS, TTFB e FCP: sono i cinque numeri che compongono i Core Web Vitals e il Lighthouse Performance score. Se voglio poi debuggare INP in campo aggiungo long-animation-frame. Prima di aggiungere un entry type controlla sempre PerformanceObserver.supportedEntryTypes per evitare eccezioni sui browser che non lo supportano (banalità, ma succede più spesso di quanto pensi).
// Feature detection prima di registrare gli observer
const supported = new Set(PerformanceObserver.supportedEntryTypes);
if (supported.has('largest-contentful-paint')) {
observeLCP();
}
if (supported.has('event')) {
observeINP();
} else if (supported.has('first-input')) {
observeFID(); // fallback
}
if (supported.has('layout-shift')) {
observeCLS();
}
Misurare LCP in produzione con buffered: true
La LCP è la metrica più fraintesa nel RUM custom. Il browser emette un entry ogni volta che compare un elemento più grande del precedente candidate, quindi puoi ricevere 3–10 entries prima che LCP sia "final". L'unico entry che conta è l'ultimo prima che l'utente interagisca con la pagina (scroll, click, keydown) o cambi tab (visibilitychange → hidden). Dopo l'interazione la LCP è "frozen" e non deve più essere aggiornata.
function observeLCP(onFinal) {
let lcpValue = 0;
let lcpEntry = null;
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
lcpValue = last.startTime;
lcpEntry = last;
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
const stop = () => {
observer.takeRecords(); // svuota entries pendenti
observer.disconnect();
onFinal({
name: 'LCP',
value: lcpValue,
element: lcpEntry?.element?.tagName,
url: lcpEntry?.url, // per immagini/video
loadTime: lcpEntry?.loadTime,
renderTime: lcpEntry?.renderTime,
});
};
// "Frozen" al primo dei tre eventi:
['keydown', 'click'].forEach(evt =>
addEventListener(evt, stop, { once: true, capture: true })
);
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') stop();
}, { once: true });
}
Nota observer.takeRecords(): senza questa chiamata rischi di perdere entries che il browser ha già accodato ma non ha ancora consegnato alla callback. Se stai spedendo il beacon in visibilitychange hai millisecondi per finalizzare, e takeRecords() è sincrono. Nel 2026 usa anche renderTime (quando disponibile) invece di startTime per un valore più preciso. Attenzione: startTime cade in loadTime per immagini cross-origin senza Timing-Allow-Origin, che sottostima la LCP di decine di millisecondi.
Misurare INP con event timing e durationThreshold
L'INP è il P98 delle durate delle interazioni per sessione (click, keypress, tap; non hover). Osservare il tipo event senza durationThreshold genera migliaia di entries per pagina, la maggior parte sotto 8 ms. Il threshold minimo raccomandato è 40 ms (il default sarebbe 104): copre tutte le interazioni che potrebbero contribuire all'INP senza saturare il main thread con callback. Per una guida approfondita agli aspetti pratici di INP, vedi la mia guida completa all'ottimizzazione INP.
function observeINP(onUpdate) {
const interactions = new Map(); // interactionId -> max duration
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.interactionId) continue; // scarta eventi non interattivi
const prev = interactions.get(entry.interactionId) ?? 0;
if (entry.duration > prev) {
interactions.set(entry.interactionId, entry.duration);
}
}
onUpdate(computeINP(interactions));
});
observer.observe({
type: 'event',
buffered: true,
durationThreshold: 40, // minimo consentito dallo spec
});
}
function computeINP(interactions) {
const durations = [...interactions.values()].sort((a, b) => b - a);
if (durations.length === 0) return 0;
// P98: droppa 1 outlier ogni 50 interazioni
const rank = Math.min(durations.length - 1, Math.floor(durations.length / 50));
return durations[rank];
}
Se vuoi anche attribuire l'INP a script specifici, correla ogni interaction entry con i long-animation-frame entries che hanno un startTime sovrapposto. I LoAF ti danno scripts[] con sourceURL, sourceFunctionName e invoker. È così che scopri se il tuo INP è colpa di React reconciliation, di un handler di Google Tag Manager, o di un observer di terze parti (nel mio ultimo audit, sorpresa, era il primo).
Misurare CLS con session windows
Il CLS non è la somma di tutti gli shift della pagina. È il massimo di una "session window" scorrevole di 5 secondi con gap massimo di 1 secondo tra shift. Questa definizione (introdotta nel 2021 e ancora in vigore nel 2026) evita di penalizzare pagine long-lived come editor o feed infiniti. Il calcolo va fatto lato client perché il browser non lo espone direttamente. Se il concetto di CLS è nuovo, la guida pratica al CLS copre le cause e i fix più comuni.
function observeCLS(onUpdate) {
let clsValue = 0;
let sessionValue = 0;
let sessionEntries = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Ignora shift causati da input recente (non contano per CLS)
if (entry.hadRecentInput) continue;
const first = sessionEntries[0];
const last = sessionEntries[sessionEntries.length - 1];
const withinGap = last && entry.startTime - last.startTime < 1000;
const withinWindow = first && entry.startTime - first.startTime < 5000;
if (withinGap && withinWindow) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}
if (sessionValue > clsValue) {
clsValue = sessionValue;
onUpdate({ name: 'CLS', value: clsValue, entries: sessionEntries });
}
}
}).observe({ type: 'layout-shift', buffered: true });
}
Il campo hadRecentInput è cruciale. Se uno shift avviene entro 500 ms da un click/keydown/scroll, il browser lo marca come "user-initiated" e non deve contare nel CLS. Ho debuggato più di un RUM che riportava CLS a 0.8 su modal aperti dall'utente, semplicemente perché lo script ignorava questo flag. Nel 2026 sources[] è supportato in Chrome, Edge e Firefox e ti dà i nodi DOM che si sono spostati: essenziale per l'attribution. Spedisci in beacon il tagName più un selettore breve, mai il selettore completo (rischi di catturare contenuto sensibile in id o attributi).
Invio affidabile dei dati con sendBeacon
Il momento più critico del RUM è la spedizione. fetch() senza keepalive: true viene abortito quando il browser scarica la pagina. XMLHttpRequest sincrono blocca il rendering. L'unico approccio garantito è navigator.sendBeacon() per la spedizione finale, con un fallback a fetch(url, { keepalive: true }) per browser che rifiutano payload > 64 KB (il limite di sendBeacon).
function sendRUM(payload) {
const url = '/rum/collect';
const body = JSON.stringify(payload);
// sendBeacon: garantito durante page unload, max 64 KB
if (navigator.sendBeacon && body.length < 60_000) {
const blob = new Blob([body], { type: 'application/json' });
if (navigator.sendBeacon(url, blob)) return;
}
// fallback: fetch keepalive
fetch(url, {
method: 'POST',
body,
keepalive: true,
headers: { 'Content-Type': 'application/json' },
}).catch(() => { /* fire and forget */ });
}
// Spedisci al primo dei tre trigger
let sent = false;
function flush() {
if (sent) return;
sent = true;
sendRUM(collectAllMetrics());
}
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush); // iOS Safari, bfcache-safe
Perché visibilitychange → hidden e non beforeunload? Su mobile (iOS in particolare) beforeunload spesso non spara: l'utente cambia tab o mette in background l'app e il browser sospende JavaScript senza mai eseguire l'handler. visibilitychange, invece, è affidabile su ogni piattaforma dal 2019. Aggiungi pagehide come cintura di sicurezza per Safari e per le pagine che entrano in bfcache: entrambi gli eventi dovrebbero far scattare il beacon una sola volta grazie al flag sent.
Correlazione con metadati di sessione
Numeri di LCP senza contesto sono inutili. Un LCP a 3200 ms su un iPhone 15 in 5G è un disastro; lo stesso valore su un Android entry-level in 3G è eccellente. Il RUM diventa azionabile quando puoi filtrare per dispositivo, rete, route e build. Ecco il set minimo che spedisco sempre:
function collectMetadata() {
const nav = navigator;
const conn = nav.connection || {};
return {
// Identità
sessionId: getOrCreateSessionId(),
buildId: window.__BUILD_ID__, // iniettato al build time
// Contesto
url: location.pathname,
referrer: document.referrer,
// Dispositivo
deviceMemory: nav.deviceMemory, // GB, in step di 0.25/0.5/1/2/4/8
hardwareConcurrency: nav.hardwareConcurrency,
// Rete
effectiveType: conn.effectiveType, // '4g' | '3g' | '2g' | 'slow-2g'
downlink: conn.downlink, // Mbps
rtt: conn.rtt, // ms
saveData: conn.saveData,
// Viewport
viewportWidth: innerWidth,
dpr: devicePixelRatio,
// A/B test
experiments: window.__EXPERIMENTS__,
};
}
Il buildId è quello che ti salva quando spedisci una regressione. Filtri il P75 di INP per buildId, vedi un salto di 40 ms nella release delle 14:32, apri il diff e capisci perché. Senza buildId stai debuggando alla cieca (l'ho imparato a mie spese, un venerdì sera). La Network Information API è disponibile solo su Chromium; su Safari e Firefox questi campi saranno undefined, che di per sé è un dato (lo tratti come segmento "unknown").
RUM custom vs web-vitals library vs vendor
Ci sono tre approcci comuni. La libreria web-vitals di Google incapsula tutta la logica sopra in ~2 KB gzipped ed è aggiornata al passo con le modifiche allo spec: è il default sensato per il 90% dei team. Un RUM completamente custom ha senso solo se hai requisiti specifici (attribuzione custom, integrazione stretta con il tuo stack di logging, edge collector). Le soluzioni vendor (Datadog RUM, New Relic Browser, Sentry Performance) fanno cose che web-vitals non fa (session replay, error correlation) ma pagano un costo di bundle di 40–120 KB.
Dimensione
RUM custom
web-vitals library
Vendor (Datadog/Sentry)
Bundle size (gzipped)
2–3 KB
~2 KB
40–120 KB
Effort di sviluppo
2–3 giorni
2 ore
< 1 ora
Manutenzione spec-drift
Tua
Google
Vendor
Costo per 1M sessioni/mese
Solo storage
Solo storage
$150–$800
Session replay
No
No
Sì
Attribuzione LoAF
Custom
Sì (v4+)
Sì
Correlazione error → perf
Custom
No
Sì
La mia raccomandazione operativa, dopo aver fatto entrambe le scelte in progetti diversi: parti con web-vitals più un tuo endpoint di collezione (5 righe di codice sopra la libreria). Passi al RUM custom solo quando hai bisogno di logica non coperta (es. contare shift solo dentro certi selettori CSS). I vendor hanno senso se il valore aggiuntivo (session replay, error linking) giustifica il bundle overhead, e comunque puoi caricarli con type="module" e defer per non impattare LCP.
Errori comuni da evitare
Ho fatto code review su decine di RUM custom, e questi sono gli errori che ricorrono. Metterli in checklist ti salva settimane di dati inaffidabili.
Dimenticare buffered: true: perdi ogni entry che il browser ha registrato prima che il tuo script eseguisse. Su script caricati con defer significa perdere la LCP nel 30–50% delle sessioni.
Usare startTime invece di gestire renderTime/loadTime per LCP: sottostima LCP per immagini cross-origin senza Timing-Allow-Origin.
Trattare CLS come somma cumulativa: è il massimo di session windows, non un totale. Il tuo dashboard mostrerà valori molto più alti di quelli che vede il CrUX.
Spedire in beforeunload: non spara su mobile. Usa visibilitychange → hidden più pagehide.
Non aggiungere durationThreshold a event: sommergi il main thread di callback per interazioni di 4 ms che non contano.
Ignorare hadRecentInput nei layout shift: inflaziona il CLS di 3–10x su siti con modal e overlay.
Non spedire il buildId: non puoi correlare le regressioni ai deploy.
Campionare all'invio invece che alla raccolta: se raccogli sempre e campioni solo la spedizione, hai fatto il lavoro per niente. Decidi il sampling all'inizio della sessione.
Domande Frequenti
Qual è la differenza tra PerformanceObserver e performance.getEntries()?
getEntries() è pull: fai polling contro un buffer che può essere troncato (default 150 entries). PerformanceObserver è push: ricevi ogni entry via callback quando il browser lo registra, e con buffered: true ricevi anche gli entries già presenti nel buffer al momento della registrazione. In produzione usa sempre PerformanceObserver.
Perché il mio RUM riporta valori CLS più alti del CrUX?
Quasi sempre perché stai sommando tutti i layout-shift entries invece di calcolare le session windows. Il CLS ufficiale è il massimo di una finestra di 5 secondi con gap massimo di 1 secondo tra shift, escludendo gli entries con hadRecentInput === true. Somma cumulativa e mancata gestione di hadRecentInput sono le due cause principali di CLS inflazionato.
sendBeacon o fetch keepalive: quale usare?
Usa sendBeacon come prima scelta perché è garantito durante page unload su tutti i browser. Ha limiti: max 64 KB per payload e non ti dà controllo su headers custom oltre Content-Type. Se il tuo payload supera 60 KB o ti servono headers custom (es. Authorization), fai fallback a fetch(url, { keepalive: true }), che ha comportamento simile ma con più flessibilità.
Come misuro l'INP di una singola interazione nel campo?
Osserva il tipo event con durationThreshold: 40 e raggruppa gli entries per interactionId (prendendo la duration massima per gruppo). L'INP della sessione è il P98 di quei massimi. Per attribuire il tempo speso, correla ogni interaction entry con i long-animation-frame entries che si sovrappongono temporalmente. LoAF ti dà l'attribuzione a script sorgente.
Il RUM custom impatta le Core Web Vitals che misura?
Se scritto bene, no: PerformanceObserver esegue le callback in task separati, sendBeacon è async e non blocca il rendering, e il codice pesa 2–3 KB gzipped. Il problema sorge quando spedisci beacon a ogni entry (spedisci solo a visibilitychange) o quando fai lavoro sincrono pesante nelle callback (calcoli il P98 solo alla fine, non a ogni update).
Come gestisco il campionamento nel RUM per contenere i costi?
Campiona alla creazione della sessione, non alla spedizione. Un hash uniforme (es. crypto.getRandomValues() mod 100 < sampleRate) applicato una volta e conservato in memoria per tutta la sessione garantisce che gli utenti campionati generino un dataset completo. Non campionare per browser o dispositivo perché biasa i risultati verso segmenti veloci.
Guida pratica alla View Transitions API cross-document per siti multi-pagina nel 2026: come attivarla con una regola CSS, personalizzarla con pageswap/pagereveal e combinarla con Speculation Rules senza impattare Core Web Vitals.
content-visibility: auto e la proprieta CSS che salta il rendering degli elementi fuori viewport. Guida 2026 con esempi pratici, supporto browser aggiornato e trappole comuni da evitare.
Configura stale-while-revalidate e stale-if-error per servire contenuti istantanei con TTFB vicino allo zero. Include esempi Cloudflare, Fastly, Vercel e Next.js 15.