Core Web Vitals in pratica: cosa misurare e cosa cambiare

di

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 width e height
  • 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.

← Tutti i post