Aggiornato: 13 settembre 2026
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.
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 | 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 condefersignifica perdere la LCP nel 30–50% delle sessioni. - Usare
startTimeinvece di gestirerenderTime/loadTimeper LCP: sottostima LCP per immagini cross-origin senzaTiming-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. Usavisibilitychange → hiddenpiùpagehide. - Non aggiungere
durationThresholdaevent: sommergi il main thread di callback per interazioni di 4 ms che non contano. - Ignorare
hadRecentInputnei 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.




