Core Web Vitals in pratica: cosa misurare e cosa cambiare
di Marco Crotti
I Core Web Vitals sono tre metriche con cui Google prova a quantificare quanto una pagina è sgradevole da usare: quanto ci mette a mostrare qualcosa di utile, quanto salta mentre carica, quanto è lenta a rispondere ai click.
Di articoli che le elencano ce ne sono a migliaia. Questo prova a fare una cosa diversa: prendere una pagina con tre difetti tipici, misurarla, correggerli, e rimisurarla. Sotto ci sono i numeri veri, non stime.
La pagina di prova
Una pagina banale: un titolo, un’immagine larga, del testo, un bottone che filtra dei risultati. I difetti sono quelli che si trovano ovunque:
- il font caricato da Google Fonts con un
<link>nella<head> - l’immagine senza
widtheheight - il click che esegue un calcolo pesante tutto in una volta
L’immagine viene servita con un ritardo di un secondo e mezzo, per simulare una rete reale invece di un disco locale. Senza quel dettaglio molti problemi non emergono, ed è il motivo per cui spesso in locale “va tutto bene”.
LCP: quando compare la cosa più grande
Largest Contentful Paint misura quando viene disegnato l’elemento più grande visibile: di solito l’immagine principale o il titolo. La soglia è 2,5 secondi.
Nella pagina di prova il colpevole non era l’immagine, era il font. Un <link> a Google Fonts nella <head> è render-blocking: il browser deve risolvere il DNS di due domini, scaricare un CSS, e solo allora sa quale file di font gli serve. Fino ad allora non disegna testo.
Le correzioni:
<!-- prima: due domini esterni, CSS bloccante -->
<link href="https://fonts.googleapis.com/css2?family=Inter..." rel="stylesheet">
<!-- dopo: font sullo stesso dominio, dichiarato e precaricato -->
<style>
@font-face {
font-family: Inter;
src: url(inter.woff2) format('woff2');
font-display: swap;
}
</style>
<link rel="preload" href="inter.woff2" as="font" type="font/woff2" crossorigin>
Più fetchpriority="high" sull’immagine principale, che dice al browser di scaricarla prima delle altre risorse invece di trattarla come una qualsiasi.
Il risultato, misurato con Lighthouse:
| prima | dopo | |
|---|---|---|
| First Contentful Paint | 1,4 s | 0,6 s |
| Largest Contentful Paint | 2,5 s | 1,7 s |
| Speed Index | 2,0 s | 1,4 s |
| Punteggio performance | 97 | 100 |
Otto decimi di secondo sul LCP, togliendo una dipendenza esterna. È il motivo per cui il self-hosting dei font è quasi sempre la prima cosa da fare: non è una micro-ottimizzazione, è il collo di bottiglia.
CLS: quanto la pagina salta
Cumulative Layout Shift misura quanto il contenuto si sposta mentre carica. La soglia è 0,1, e a differenza delle altre non è un tempo ma un punteggio: quanta superficie dello schermo si è mossa, per quanta distanza.
È il problema più fastidioso per chi legge, perché colpisce nel momento peggiore: stai per toccare un link, arriva l’immagine, tutto scende di duecento pixel e tocchi qualcos’altro.
La causa, nella pagina di prova, è una riga mancante. Il browser scopre le dimensioni di un’immagine solo quando l’ha scaricata; fino a quel momento le riserva zero spazio, e quando arriva spinge in basso tutto il resto.
<!-- prima -->
<img src="hero.jpg">
<!-- dopo -->
<img src="hero.jpg" width="1200" height="630" alt="Catalogo">
| prima | dopo | |
|---|---|---|
| Cumulative Layout Shift | 0,049 | 0 |
Gli attributi width e height non fissano la dimensione di visualizzazione: con max-width: 100%; height: auto l’immagine resta responsive. Servono al browser per calcolare le proporzioni e riservare lo spazio in anticipo. Vanno messi sempre, anche quando il CSS ridimensiona tutto.
Vale per qualsiasi cosa arrivi in ritardo: banner dei cookie, pubblicità, contenuti caricati via JavaScript. Se compaiono sopra a qualcosa che c’era già, spostano la pagina.
INP: e qui il laboratorio non basta
Interaction to Next Paint misura quanto passa tra un’interazione e il momento in cui lo schermo mostra il risultato. La soglia è 200 ms. Ha sostituito il vecchio FID nel 2024, ed è più severo: FID misurava solo il ritardo prima che il gestore partisse, INP arriva fino al ripaint.
Qui c’è il punto che gli articoli sull’argomento spesso non dicono: Lighthouse non misura INP. Non può, perché in laboratorio nessuno clicca. Nel report trovi il Total Blocking Time, che è un’approssimazione di quanto il thread principale sarà occupato durante il caricamento, ma non ti dice niente su cosa succede quando l’utente preme un bottone.
Nella pagina di prova il TBT è 0 ms in entrambe le versioni, prima e dopo. Eppure una delle due blocca il browser per mezzo secondo a ogni click. Fidarsi del solo punteggio Lighthouse significa non vedere proprio niente di tutto questo.
Ho provato a misurare INP a mano, con un click reale e la PerformanceObserver sugli eventi. I numeri oscillavano di un ordine di grandezza fra un tentativo e l’altro, perché il motore JavaScript ottimizza il codice dopo le prime esecuzioni. Ed è esattamente la ragione per cui INP è una metrica di campo: ha senso solo aggregata su migliaia di visite vere, con dispositivi e condizioni diverse.
Per misurarla ci sono due strade. La prima è guardare i dati che Google raccoglie dagli utenti di Chrome, dentro Search Console alla voce “Segnali web essenziali”, o in PageSpeed Insights nella sezione sui dati reali. La seconda è raccoglierla da sé:
import { onINP, onLCP, onCLS } from 'web-vitals'
onINP(console.log)
onLCP(console.log)
onCLS(console.log)
La libreria web-vitals è quella ufficiale di Google e pesa poco più di un kilobyte. Al posto di console.log ci si mette la chiamata al proprio sistema di analytics.
Sul lato correzione, il principio è uno solo: non bloccare il thread principale. Il browser ha un solo thread per JavaScript, layout e disegno; finché è impegnato in un calcolo, non può rispondere a niente. Se un’operazione è lunga, va spezzata:
// prima: un blocco unico, il browser non respira
for (let i = 0; i < 60000000; i++) s += i % 7
// dopo: a pezzi, fra uno e l'altro il browser può disegnare
let i = 0
const step = () => {
const end = Math.min(i + 5000000, 60000000)
for (; i < end; i++) s += i % 7
if (i < 60000000) setTimeout(step, 0)
else mostraRisultato(s)
}
step()
Il lavoro totale è identico, e in tempo assoluto la seconda versione è persino un po’ più lenta. Ma l’interfaccia resta viva, ed è quello che INP misura: non quanto ci mette il calcolo, ma quanto ci mette la pagina a rispondere.
Nelle applicazioni React il problema si presenta diverso ma è lo stesso: un re-render costoso su un albero grande occupa il thread esattamente come un ciclo. Gli strumenti cambiano, il vincolo no.
Il riassunto
| Metrica | Soglia | Dove si misura | Causa più comune |
|---|---|---|---|
| LCP | 2,5 s | laboratorio e campo | risorse bloccanti, immagini pesanti |
| CLS | 0,1 | laboratorio e campo | immagini senza dimensioni, contenuti inseriti in ritardo |
| INP | 200 ms | solo campo | thread principale bloccato |
Due cose da portarsi via.
Le correzioni di LCP e CLS sono quasi sempre banali: dimensioni sulle immagini, font in locale, precaricare la risorsa principale. Sono mezz’ora di lavoro e si vedono subito, senza riscrivere niente.
Il 100 in Lighthouse non è la fine del discorso. Misura il caricamento di una pagina, su una macchina ferma, senza nessuno che la usi. È utile e conviene averlo, ma i dati che Google usa per il ranking sono quelli raccolti dagli utenti veri. Se le due cose non coincidono, quelli che contano sono i secondi.