Core Web Vitals da fullstack: che cosa tocca al server e che cosa al browser
Tre numeri decidono se una pagina è «veloce» per Google e per chi la usa: LCP, INP e CLS, valutati al 75° percentile delle visite reali, con soglie di 2,5 secondi, 200 millisecondi e 0,1 (web.dev, Web Vitals, consultata il 2026-11-13). Chi lavora sia sul frontend sia sul backend ha un vantaggio che il solo specialista di CSS non ha: sa che una parte consistente dell'LCP si decide prima che il browser abbia ricevuto il primo byte, e che il CLS spesso nasce da un campo mancante nel template del server. In questo articolo costruisci tre piccole cose su un progetto di prova, eseguite in-process con il client di test di Fastify: le intestazioni di cache che tolgono byte dal secondo caricamento, una funzione di template che non lascia uscire un'immagine senza dimensioni, e un endpoint che raccoglie le misure dei visitatori reali. Il precedente della serie è GraphQL contro REST; questo si occupa di quanto costa far arrivare al browser ciò che ha chiesto.
Che cosa si misura davvero, e perché il 75° percentile
Un esempio rende l'idea. Quattro visite a una pagina del blog riportano un LCP di 1,8, 2,1, 2,6 e 3,9 secondi. La media sarebbe 2,6 secondi, ma la metrica che conta è il 75° percentile: il valore sotto il quale cade il 75% delle visite. Qui è 2,6 secondi, quindi la pagina non supera la soglia di 2,5 anche se due visite su quattro sono state ottime. Il 75° percentile è una scelta pensata per non farsi ingannare né dalle visite eccezionali né dalla coda peggiore: web.dev raccomanda di misurarlo separando mobile e desktop, e di considerare la pagina promossa solo se tutte e tre le metriche rispettano la soglia.
Le soglie di «scarso» non stanno nella pagina di web.dev ma nella libreria web-vitals, che le esporta come costanti: 4000 ms per LCP, 500 ms per INP, 0,25 per CLS (lette dalle costanti LCPThresholds, INPThresholds e CLSThresholds della versione 6.2.3, importate nel listato che segue). In mezzo c'è la fascia «da migliorare». Una nota di data, perché capita ancora di leggere il contrario: il 12 marzo 2024 l'INP ha sostituito il FID come metrica di reattività (web.dev, annuncio dell'INP), e la libreria non ha più una funzione per il FID.
LCP: quanta parte ne controlla il server
web.dev scompone l'LCP in quattro tempi consecutivi: il tempo al primo byte (TTFB), il ritardo prima che il browser inizi a scaricare la risorsa dell'elemento più grande, la durata di quel download e il ritardo di rendering. Come ordine di grandezza la guida indica circa il 40% per il TTFB, meno del 10% per il ritardo di caricamento, circa il 40% per la durata del download e meno del 10% per il ritardo di rendering (web.dev, Optimize LCP), precisando che sono indicazioni e non regole. Il messaggio pratico è che due fasi su quattro dipendono quasi interamente dal server: quanto ci mette a rispondere con l'HTML e quanto pesano i byte che deve mandare.
Il listato che segue mostra le due intestazioni che incidono di più sul secondo caricamento. L'HTML porta Cache-Control: no-cache e un ETag: «no-cache» non vuol dire «non memorizzare», vuol dire «riconvalida prima di usare», quindi alla rivisita il browser manda If-None-Match e il server risponde 304 senza corpo. Gli asset hanno invece un'impronta del contenuto nel nome (hero.3f9c2a.avif) e ricevono max-age=31536000, immutable: il file con quel nome non cambierà mai, quindi il browser può tenerlo un anno senza chiedere nulla. Se togli l'impronta dal nome ma lasci immutable, il browser non vedrà più i tuoi aggiornamenti per un anno: l'impronta è ciò che rende sicuro quel valore. Sulla direttiva immutable la fonte è MDN, Cache-Control.
import Fastify from 'fastify';
import { createHash } from 'node:crypto';
const app = Fastify();
const pagina = '<!doctype html><title>Demo</title><img src="/assets/hero.3f9c2a.avif" width="1200" height="630" alt="">';
const etag = '"' + createHash('sha256').update(pagina).digest('base64url').slice(0, 16) + '"';
// L'HTML cambia a ogni rilascio: si puo' memorizzare, ma va riconvalidato ogni volta.
app.get('/', async (req, reply) => {
reply.header('Cache-Control', 'no-cache').header('ETag', etag).type('text/html');
if (req.headers['if-none-match'] === etag) return reply.code(304).send();
return pagina;
});
// Il nome contiene l'impronta del contenuto: quel file non cambia mai, quindi un anno.
app.get('/assets/:file', async (req, reply) => {
reply.header('Cache-Control', 'public, max-age=31536000, immutable').type('image/avif');
return Buffer.from('contenuto-finto');
});
for (const [etichetta, richiesta] of [
['HTML, prima visita ', { url: '/' }],
['HTML, rivisita ', { url: '/', headers: { 'if-none-match': etag } }],
['asset con impronta ', { url: '/assets/hero.3f9c2a.avif' }],
]) {
const r = await app.inject(richiesta);
console.log(etichetta, r.statusCode, '|', r.headers['cache-control'], '| corpo:', r.rawPayload.length, 'byte');
}
// Output:
// HTML, prima visita 200 | no-cache | corpo: 103 byte
// HTML, rivisita 304 | no-cache | corpo: 0 byte
// asset con impronta 200 | public, max-age=31536000, immutable | corpo: 15 byte
L'uscita, ottenuta con Node 24.11 e Fastify 5.12.8 e identica in due esecuzioni consecutive, mostra i tre casi: prima visita (200 con 103 byte), rivisita dell'HTML (304 e zero byte nel corpo) e asset con impronta (cache di un anno). I 103 byte sono quelli del documento di prova, non una stima per una pagina vera. Il listato non misura tempi e non apre porte: usa inject(), che fa passare la richiesta nel server senza rete. Per questo prova la correttezza delle intestazioni, non la velocità: la velocità si misura nei dati di campo, nell'ultima sezione.
Resta la seconda fase dell'LCP, il ritardo prima che il download parta. Se l'elemento più grande è un'immagine, il browser non la scopre finché non legge l'HTML, e di default non le dà priorità alta, perché le immagini non bloccano il rendering. web.dev consiglia fetchpriority="high" sull'immagine che probabilmente sarà l'LCP, e avverte di non rendere mai pigra (loading="lazy") quell'immagine, perché il caricamento partirebbe solo dopo il layout. Nel prossimo listato questa regola non è una raccomandazione che qualcuno deve ricordarsi, ma una proprietà della funzione di template.
CLS: le dimensioni sono un dato del server
La causa più frequente di CLS elencata da web.dev è l'immagine senza dimensioni: il browser non sa quanto spazio riservare, la pagina si sposta quando l'immagine arriva. La guida è semplice: indicare sempre width e height su immagini e video, oppure riservare lo spazio con aspect-ratio in CSS (web.dev, Optimize CLS). Per un fullstack il punto interessante è chi possiede quelle dimensioni: le conosce il backend, che le legge dal file al momento del caricamento e le salva insieme all'indirizzo. Se il template le rende obbligatorie, un'immagine senza dimensioni non esce nemmeno dal server.
// Funzione del template lato server: rifiuta le immagini senza dimensioni
// e non permette di rendere pigra l'immagine che sara' l'LCP.
export function img({ src, alt, width, height, lcp = false }) {
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
throw new Error(`img ${src}: width e height obbligatori (interi positivi)`);
}
const attributi = lcp ? 'fetchpriority="high"' : 'loading="lazy" decoding="async"';
return `<img src="${src}" alt="${alt}" width="${width}" height="${height}" ${attributi}>`;
}
console.log(img({ src: '/a/hero.avif', alt: 'Copertina', width: 1200, height: 630, lcp: true }));
console.log(img({ src: '/a/foto.avif', alt: 'Foto', width: 800, height: 450 }));
try { img({ src: '/a/rotta.avif', alt: '', width: 800 }); } catch (e) { console.log('Errore:', e.message); }
// Output:
// <img src="/a/hero.avif" alt="Copertina" width="1200" height="630" fetchpriority="high">
// <img src="/a/foto.avif" alt="Foto" width="800" height="450" loading="lazy" decoding="async">
// Errore: img /a/rotta.avif: width e height obbligatori (interi positivi)
La funzione img fa due cose. Lancia un errore se width o height non sono interi positivi: l'ultima riga dell'uscita mostra il messaggio per un'immagine a cui manca l'altezza. Poi distingue i due casi con un solo parametro: l'immagine lcp riceve fetchpriority="high" e non può diventare pigra, tutte le altre ricevono loading="lazy" e decoding="async". Il listato non prova nulla sul rendering, perché non c'è un browser: prova che l'HTML prodotto ha gli attributi giusti. Se il tuo frontend è Angular, la stessa idea si trasferisce al componente che renderizza l'immagine, ma quel codice non l'ho eseguito.
INP: il tuo contributo sta nel non bloccare il thread principale
L'INP misura la latenza delle interazioni e web.dev la scompone in tre parti: il ritardo di input (dal gesto all'avvio degli handler), la durata dell'elaborazione e il ritardo di presentazione, cioè il tempo per mostrare il frame successivo (web.dev, Optimize INP). Il server c'entra poco con queste tre fasi, ma c'entra il modo in cui disegni l'API. Una risposta enorme da elaborare nel browser, una lista che richiede di ricalcolare tutto a ogni tasto, o un handler che aspetta la rete prima di aggiornare l'interfaccia allungano la seconda e la terza fase. Le correzioni sono quasi tutte nel frontend, e per quelle rimando ai testi di web.dev: non le ho riprodotte qui, perché senza un browser non ho modo di misurare un INP.
- Ritardo di input: spezzare i task lunghi sul thread principale, perché l'interazione aspetta che il task in corso finisca.
- Durata dell'elaborazione: fare nell'handler solo l'aggiornamento visibile e rimandare il resto.
- Ritardo di presentazione: ridurre la quantità di DOM e di stili da ricalcolare per il frame successivo.
L'elenco è ordinato secondo il percorso dell'evento, dal gesto allo schermo, e la fase su cui intervenire dipende da dove il tuo sito perde tempo: per saperlo non basta indovinare, servono i dati di campo con l'attribuzione, che sono l'argomento della sezione seguente. Se la quota maggiore è il ritardo di input, ottimizzare l'handler non cambia quasi nulla.
Raccogliere le misure dei visitatori: client e server
I punteggi di laboratorio dicono che cosa potrebbe andare storto; i dati di campo dicono che cosa va storto per chi usa il sito. La libreria web-vitals fornisce le funzioni onLCP, onINP e onCLS, e una variante con attribuzione che spiega da dove viene un valore alto: per l'LCP restituisce i quattro tempi della scomposizione sopra, per l'INP i tre dell'interazione. Il client che segue è stato compilato con TypeScript 7.0.2 in modalità strict contro web-vitals 6.2.3, ma non eseguito, perché richiede un browser. Un dettaglio che si scopre solo compilando: nella versione 6 l'attribuzione dell'LCP chiama target il selettore dell'elemento; un campo element non esiste: scrivendo m.attribution.element il compilatore risponde con l'errore TS2339, «Property 'element' does not exist on type 'LCPAttribution'», che ho provato in un file a parte.
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals/attribution';
type Invio = { name: string; value: number; path: string; detail: Record<string, unknown> };
const coda = new Map<string, Invio>();
function accoda(m: Metric & { attribution: object }): void {
coda.set(m.id, { name: m.name, value: m.value, path: location.pathname, detail: { ...m.attribution } });
}
function svuota(): void {
if (coda.size === 0) return;
for (const invio of coda.values()) {
const corpo = new Blob([JSON.stringify(invio)], { type: 'application/json' });
navigator.sendBeacon('/rum', corpo);
}
coda.clear();
}
onLCP(accoda);
onINP(accoda);
onCLS(accoda);
// L'ultimo momento affidabile per spedire e' quando la pagina passa in secondo piano.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') svuota();
});
Le due scelte che contano sono la coda e il momento dell'invio. Le metriche possono scattare più di una volta, per esempio il CLS cresce mentre la pagina vive, quindi la coda è una mappa indicizzata dall'id della metrica: l'ultimo valore sostituisce il precedente. Il momento giusto per spedire è visibilitychange verso hidden, l'ultimo evento affidabile prima che la pagina sparisca, e il mezzo è sendBeacon, pensato per partire anche durante lo scaricamento della pagina, con un limite di circa 64 KiB per il totale dei dati in coda (MDN, sendBeacon). Il corpo è un Blob con tipo application/json: se passassi la stringa direttamente, il Fetch Standard le assegna il tipo text/plain;charset=UTF-8 (Fetch Standard, estrazione del corpo), e il tuo server dovrebbe saperlo.
Il server sotto è il lato di cui devi fidarti meno. Chiunque può mandare un POST al tuo endpoint, quindi il corpo si valida con uno schema: nome ammesso solo fra tre valori, valore numerico non negativo e con un massimo, percorso di lunghezza limitata. Il listato prova entrambi i modi di arrivo del corpo, il JSON dichiarato e il text/plain, poi due invii sbagliati (un valore negativo e un nome di metrica che non esiste più, FID) e infine calcola il 75° percentile con un secondo calcolo indipendente per conferma.
import Fastify from 'fastify';
import { LCPThresholds, INPThresholds, CLSThresholds } from 'web-vitals';
const SOGLIE = { LCP: LCPThresholds, INP: INPThresholds, CLS: CLSThresholds };
const raccolta = [];
const app = Fastify();
// sendBeacon con una stringa viaggia come text/plain: la convertiamo noi.
app.addContentTypeParser('text/plain', { parseAs: 'string' }, (req, body, done) => {
try { done(null, JSON.parse(body)); } catch (e) { done(e); }
});
app.post('/rum', {
schema: {
body: {
type: 'object',
required: ['name', 'value', 'path'],
properties: {
name: { enum: ['LCP', 'INP', 'CLS'] },
value: { type: 'number', minimum: 0, maximum: 600000 },
path: { type: 'string', maxLength: 200 },
detail: { type: 'object', maxProperties: 20 },
},
additionalProperties: false,
},
},
}, async (req, reply) => {
raccolta.push(req.body);
return reply.code(204).send();
});
function p75(valori) {
const ordinati = [...valori].sort((a, b) => a - b);
return ordinati[Math.ceil(ordinati.length * 0.75) - 1];
}
function giudizio(nome, v) {
const [buono, scarso] = SOGLIE[nome];
return v <= buono ? 'good' : v <= scarso ? 'needs-improvement' : 'poor';
}
const j = JSON.stringify;
const invii = [
['application/json', j({ name: 'LCP', value: 1800, path: '/blog', detail: { resourceLoadDelay: 40 } })],
['text/plain;charset=UTF-8', j({ name: 'LCP', value: 2100, path: '/blog' })],
['text/plain;charset=UTF-8', j({ name: 'LCP', value: 2600, path: '/blog' })],
['text/plain;charset=UTF-8', j({ name: 'LCP', value: 3900, path: '/blog' })],
['text/plain;charset=UTF-8', j({ name: 'INP', value: -5, path: '/blog' })],
['text/plain;charset=UTF-8', j({ name: 'FID', value: 20, path: '/blog' })],
];
for (const [tipo, corpo] of invii) {
const r = await app.inject({ method: 'POST', url: '/rum', headers: { 'content-type': tipo }, payload: corpo });
console.log(r.statusCode, tipo.split(';')[0], corpo);
}
const lcp = raccolta.filter((m) => m.name === 'LCP').map((m) => m.value);
const v = p75(lcp);
console.log('LCP accettati:', lcp.length, 'p75 =', v, 'ms ->', giudizio('LCP', v));
// Secondo calcolo, indipendente: il p75 e' il piu' piccolo valore con almeno il 75% dei campioni <= a lui.
const minimo = Math.min(...lcp.filter((x) => lcp.filter((y) => y <= x).length / lcp.length >= 0.75));
console.log('verifica per conteggio:', minimo, minimo === v ? 'uguale' : 'DIVERSO');
// Output:
// 204 application/json {"name":"LCP","value":1800,"path":"/blog","detail":{"resourceLoadDelay":40}}
// 204 text/plain {"name":"LCP","value":2100,"path":"/blog"}
// 204 text/plain {"name":"LCP","value":2600,"path":"/blog"}
// 204 text/plain {"name":"LCP","value":3900,"path":"/blog"}
// 400 text/plain {"name":"INP","value":-5,"path":"/blog"}
// 400 text/plain {"name":"FID","value":20,"path":"/blog"}
// LCP accettati: 4 p75 = 2600 ms -> needs-improvement
// verifica per conteggio: 2600 uguale
Quattro invii sono accettati (204) e due rifiutati (400), e il 75° percentile dei quattro LCP è 2600 ms, quindi «da migliorare», coerente con l'esempio dell'inizio. Il secondo calcolo, per conteggio (il più piccolo valore con almeno il 75% dei campioni uguali o inferiori), dà lo stesso numero. Due note sul codice. Il parser text/plain va registrato esplicitamente: senza, Fastify consegna il corpo come stringa grezza (verificato in un listato a parte: il tipo del corpo risulta string), e lo schema lo rifiuterebbe. E il percentile qui usa il metodo del rango più vicino su pochi campioni: su dati reali servono molti campioni per sessione di misura, ma anche il percentile esatto non dice nulla senza segmentare per mobile e desktop.
Che cosa sai fare adesso
Sai scomporre un LCP alto nei suoi quattro tempi e dire quali due appartengono al server; sai impostare le intestazioni di cache per HTML e asset con impronta senza rischiare di servire contenuto vecchio; sai impedire dal template che esca un'immagine senza dimensioni; e sai raccogliere le misure dei visitatori con un endpoint che le valida. Quello che non hai ancora è l'automazione di questi controlli a ogni rilascio: ne parla l'ultimo articolo della serie, sulla CI/CD multi-ambiente. Per la misura in produzione approfondita, con CrUX e le regressioni, c'è l'articolo Misurare e monitorare i Core Web Vitals in produzione, e per la superficie di attacco dei beacon e degli endpoint pubblici il prossimo della serie, Sicurezza web. Gli altri articoli della serie, dal primo all'ottavo, si trovano nella pagina della serie.
Domande frequenti
Perché il 75° percentile e non la media?
Perché web.dev lo indica come soglia da misurare, separando mobile e desktop. La media si lascia trascinare dalle visite eccezionalmente veloci o lente; il 75° percentile dice che tre visite su quattro sono state almeno così buone.
Perché sendBeacon e non fetch?
Perché la pagina può chiudersi nel momento in cui vuoi spedire il dato, e una richiesta asincrona normale rischia di essere annullata. sendBeacon è progettato per partire senza rallentare lo scaricamento, con un tetto di circa 64 KiB di dati in coda.
Posso usare immutable su qualsiasi file?
No, solo su file il cui nome cambia quando cambia il contenuto, per esempio con un'impronta nel nome. Su un indirizzo stabile come l'HTML della pagina, immutable bloccherebbe gli aggiornamenti fino alla scadenza.
Fonti
- Web Vitals (web.dev) - soglie good al 75° percentile e criterio di segmentazione per dispositivo, consultata il 2026-11-13
- Interaction to Next Paint becomes a Core Web Vital on March 12 (web.dev) - sostituzione del FID con l'INP, consultata il 2026-11-13
- Optimize Largest Contentful Paint (web.dev) - le quattro sottoparti e le proporzioni indicative, fetchpriority e divieto di lazy loading dell'immagine LCP, consultata il 2026-11-13
- Optimize Cumulative Layout Shift (web.dev) - width, height e aspect-ratio sui media, consultata il 2026-11-13
- Optimize Interaction to Next Paint (web.dev) - le tre parti di un'interazione, consultata il 2026-11-13
- Cache-Control (MDN) - significato di immutable e uso con nomi di file con impronta, consultata il 2026-11-13
- Navigator.sendBeacon() (MDN) - invio durante lo scaricamento della pagina e limite di 64 KiB, consultata il 2026-11-13
- Fetch Standard (WHATWG) - tipo text/plain;charset=UTF-8 assegnato alle stringhe quando si estrae un corpo, consultata il 2026-11-13
- GoogleChrome/web-vitals - onLCP, onINP, onCLS e uso di sendBeacon; versione 6.2.3 dal registro npm, consultata il 2026-11-13
I listati Node (cache, template, raccolta delle misure) sono stati eseguiti con Node 24.11, Fastify 5.12.8 e inject(), due volte con uscita identica; nessuna porta aperta. Il client TypeScript è stato compilato con TypeScript 7.0.2, non eseguito: manca un browser. Le soglie di «scarso» sono state lette dalle costanti esportate da web-vitals 6.2.3. Nessun tempo di caricamento è stato misurato.
Commenti
Caricamento commenti...
Accedi per commentare