Core Web Vitals nel 2026: cosa fa davvero la differenza
Lascia perdere la teoria — ecco cosa ho fatto concretamente per portare l'LCP sotto i 2.5s, il CLS a zero e l'INP sotto i 200ms su un sito Next.js in produzione. Tecniche specifiche, non consigli vaghi.
Ho passato le ultime due settimane a rendere veloce questo sito. Non "sembra veloce in un audit Lighthouse sul mio MacBook M3" veloce. Davvero veloce. Veloce su un telefono Android da 150€ con una connessione 4G traballante in un tunnel della metropolitana. Veloce dove conta.
Il risultato: LCP sotto 1.8s, CLS a 0.00, INP sotto 120ms. Tutti e tre verdi nei dati CrUX, non solo nei punteggi di laboratorio. E ho imparato qualcosa nel processo — la maggior parte dei consigli sulle performance su internet sono o obsoleti, o vaghi, o entrambi.
"Ottimizza le tue immagini" non è un consiglio. "Usa il lazy loading" senza contesto è pericoloso. "Minimizza il JavaScript" è ovvio ma non ti dice cosa tagliare.
Ecco cosa ho fatto davvero, nell'ordine in cui ha contato.
Perché i Core Web Vitals contano ancora nel 2026#
Sarò diretto: Google usa i Core Web Vitals come segnale di ranking. Non l'unico segnale, e neanche il più importante. La rilevanza dei contenuti, i backlink e l'autorità del dominio dominano ancora. Ma ai margini — dove due pagine hanno contenuti e autorità comparabili — le performance fanno da spareggio. E su internet, milioni di pagine vivono a quei margini.
Ma lascia perdere la SEO per un secondo. La vera ragione per preoccuparsi delle performance sono gli utenti. I dati non sono cambiati molto negli ultimi cinque anni:
- Il 53% delle visite mobile viene abbandonato se una pagina impiega più di 3 secondi a caricarsi (ricerca Google/SOASTA, ancora valida)
- Ogni 100ms di latenza costa circa l'1% in conversioni (il dato originale di Amazon, validato ripetutamente)
- Gli utenti che sperimentano spostamenti del layout sono significativamente meno propensi a completare un acquisto o compilare un form
I Core Web Vitals nel 2026 consistono in tre metriche:
| Metrica | Cosa misura | Buono | Da migliorare | Scarso |
|---|---|---|---|---|
| LCP | Performance di caricamento | ≤ 2.5s | 2.5s – 4.0s | > 4.0s |
| CLS | Stabilità visiva | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | Reattività | ≤ 200ms | 200ms – 500ms | > 500ms |
Queste soglie non sono cambiate da quando l'INP ha sostituito il FID a marzo 2024. Ma le tecniche per raggiungerle si sono evolute, specialmente nell'ecosistema React/Next.js.
LCP: quella che conta di più#
Il Largest Contentful Paint misura quando l'elemento visibile più grande nel viewport finisce di renderizzarsi. Per la maggior parte delle pagine, è un'immagine hero, un heading o un grande blocco di testo.
Passo 1: trova il tuo vero elemento LCP#
Prima di ottimizzare qualsiasi cosa, devi sapere qual è il tuo elemento LCP. La gente presume sia l'immagine hero. A volte è un web font che renderizza l'<h1>. A volte è un'immagine di sfondo applicata via CSS. A volte è il poster frame di un <video>.
Apri Chrome DevTools, vai al pannello Performance, registra un page load e cerca il marker "LCP". Ti dice esattamente quale elemento ha triggerato l'LCP.
Puoi anche usare la libreria web-vitals per loggarlo programmaticamente:
import { onLCP } from "web-vitals";
onLCP((metric) => {
console.log("LCP element:", metric.entries[0]?.element);
console.log("LCP value:", metric.value, "ms");
});Su questo sito, l'elemento LCP si è rivelato essere l'immagine hero sulla homepage e il primo paragrafo di testo nei post del blog. Due elementi diversi, due strategie di ottimizzazione diverse.
Passo 2: precarica la risorsa LCP#
Se il tuo elemento LCP è un'immagine, la singola cosa più impattante che puoi fare è precaricarla. Per default, il browser scopre le immagini quando analizza l'HTML, il che significa che la richiesta dell'immagine non parte fino a dopo che l'HTML è stato scaricato, analizzato, e il tag <img> è stato raggiunto. Il preload sposta quella scoperta all'inizio.
In Next.js, puoi aggiungere un link di preload nel tuo layout o pagina:
import Head from "next/head";
export default function HeroSection() {
return (
<>
<Head>
<link
rel="preload"
as="image"
href="/images/hero-optimized.webp"
type="image/webp"
fetchPriority="high"
/>
</Head>
<section className="relative h-[600px]">
<img
src="/images/hero-optimized.webp"
alt="Hero banner"
width={1200}
height={600}
fetchPriority="high"
decoding="sync"
/>
</section>
</>
);
}Nota fetchPriority="high". Questa è la più recente Fetch Priority API, ed è un game changer. Senza di essa, il browser usa le sue euristiche per dare priorità alle risorse — e quelle euristiche spesso sbagliano, specialmente quando hai più immagini above the fold.
Su questo sito, aggiungere fetchPriority="high" all'immagine LCP ha abbassato l'LCP di ~400ms. Questa è la più grande vittoria che abbia mai ottenuto da una modifica di una riga.
Passo 3: elimina le risorse che bloccano il rendering#
Il CSS blocca il rendering. Tutto. Se hai un foglio di stile da 200KB caricato via <link rel="stylesheet">, il browser non dipingerà niente fino a quando non sarà stato completamente scaricato e analizzato.
La soluzione è triplice:
-
Inlinea il CSS critico — Estrai il CSS necessario per il contenuto above-the-fold e inlinealo in un tag
<style>nell'<head>. Next.js lo fa automaticamente quando usi CSS Modules o Tailwind con il purging corretto. -
Differisci il CSS non critico — Se hai fogli di stile per contenuto below-the-fold (una libreria di animazioni per il footer, un componente grafico), caricali in modo asincrono:
<link
rel="preload"
href="/styles/charts.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
<link rel="stylesheet" href="/styles/charts.css" />
</noscript>- Rimuovi il CSS inutilizzato — Tailwind CSS v4 lo fa automaticamente con il suo motore JIT. Ma se stai importando librerie CSS di terze parti, auditale. Ho trovato una libreria di componenti che importava 180KB di CSS per un singolo componente tooltip. L'ho sostituita con un componente custom di 20 righe e ho risparmiato 170KB.
Passo 4: tempo di risposta del server (TTFB)#
L'LCP non può essere veloce se il TTFB è lento. Se il tuo server impiega 800ms a rispondere, il tuo LCP sarà almeno 800ms + tutto il resto.
Su questo sito (Node.js + PM2 + Nginx su un VPS), ho misurato il TTFB a circa 180ms su un primo accesso a freddo. Ecco cosa ho fatto per mantenerlo lì:
- ISR (Incremental Static Regeneration) per i post del blog — le pagine sono pre-renderizzate al build time e rivalidate periodicamente. La prima visita serve un file statico direttamente dalla reverse proxy cache di Nginx.
- Header di caching edge —
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400sulle pagine statiche. - Compressione Gzip/Brotli in Nginx — riduce la dimensione del trasferimento del 60-80%.
# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
# Brotli (se il modulo ngx_brotli è installato)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;Il mio prima/dopo sull'LCP:
- Prima dell'ottimizzazione: 3.8s (75esimo percentile, CrUX)
- Dopo preload + fetchPriority + compressione: 1.8s
- Miglioramento totale: 53% di riduzione
CLS: morte per mille spostamenti#
Il Cumulative Layout Shift misura quanto il contenuto visibile si sposta durante il caricamento della pagina. Un CLS di 0 significa che niente si è spostato. Un CLS superiore a 0.1 significa che qualcosa sta infastidendo visivamente i tuoi utenti.
Il CLS è la metrica che la maggior parte degli sviluppatori sottovaluta. Non lo noti sulla tua veloce macchina di sviluppo con tutto in cache. I tuoi utenti lo notano sui loro telefoni, su connessioni lente, dove i font si caricano tardi e le immagini compaiono una alla volta.
I soliti colpevoli#
1. Immagini senza dimensioni esplicite
Questa è la causa più comune di CLS. Quando un'immagine si carica, spinge verso il basso il contenuto sotto di essa. La soluzione è imbarazzantemente semplice: specifica sempre width e height sui tag <img>.
// MALE — causa layout shift
<img src="/photo.jpg" alt="Foto del team" />
// BENE — il browser riserva lo spazio prima che l'immagine si carichi
<img src="/photo.jpg" alt="Foto del team" width={800} height={450} />Se usi Next.js <Image>, gestisce automaticamente questo purché tu fornisca le dimensioni o usi fill con un contenitore genitore dimensionato.
Ma ecco il tranello: se usi la modalità fill, il contenitore genitore deve avere dimensioni esplicite altrimenti l'immagine causerà un CLS:
// MALE — il genitore non ha dimensioni
<div className="relative">
<Image src="/photo.jpg" alt="Team" fill />
</div>
// BENE — il genitore ha un aspect ratio esplicito
<div className="relative aspect-video w-full">
<Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>2. Web font che causano FOUT/FOIT
Quando un font custom si carica, il testo renderizzato nel font di fallback viene ri-renderizzato nel font custom. Se i due font hanno metriche diverse (e quasi sempre le hanno), tutto si sposta.
La soluzione moderna è font-display: swap combinato con font di fallback con dimensioni regolate:
// Usando next/font — l'approccio migliore per Next.js
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
// next/font genera automaticamente font di fallback con dimensioni regolate
// Questo elimina il CLS dal cambio di font
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}next/font è genuinamente una delle cose migliori in Next.js. Scarica i font al build time, li self-hosta (nessuna richiesta a Google Fonts a runtime), e genera font di fallback con dimensioni regolate così che lo scambio dal fallback al font custom causa zero layout shift. Ho misurato il CLS dai font a 0.00 dopo il passaggio a next/font. Prima, con un <link> standard a Google Fonts, era 0.04-0.08.
3. Iniezione di contenuto dinamico
Annunci, banner per i cookie, barre di notifica — tutto ciò che viene iniettato nel DOM dopo il render iniziale causa CLS se spinge il contenuto verso il basso.
La soluzione: riserva spazio per il contenuto dinamico prima che si carichi.
// Banner cookie — riserva spazio in basso
function CookieBanner() {
const [accepted, setAccepted] = useState(false);
if (accepted) return null;
return (
// Il posizionamento fixed non causa CLS perché
// non influenza il flusso del documento
<div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
<p>Usiamo i cookie. Conosci la procedura.</p>
<button onClick={() => setAccepted(true)}>Accetta</button>
</div>
);
}Usare position: fixed o position: absolute per gli elementi dinamici è un approccio senza CLS perché questi elementi non influenzano il normale flusso del documento.
4. Il trucco CSS aspect-ratio
Per contenitori responsivi dove conosci l'aspect ratio ma non le dimensioni esatte, usa la proprietà CSS aspect-ratio:
// Video embed senza CLS
function VideoEmbed({ src }: { src: string }) {
return (
<div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
<iframe
src={src}
className="w-full h-full"
title="Video incorporato"
allow="accelerometer; autoplay; clipboard-write; encrypted-media"
allowFullScreen
/>
</div>
);
}L'utility aspect-video (che è aspect-ratio: 16/9) riserva esattamente la giusta quantità di spazio. Nessuno spostamento quando l'iframe si carica.
5. Skeleton screen
Per il contenuto che si carica in modo asincrono (dati API, componenti dinamici), mostra uno skeleton che corrisponde alle dimensioni previste:
function PostCardSkeleton() {
return (
<div className="animate-pulse rounded-lg border p-4">
<div className="h-48 w-full rounded bg-gray-200" />
<div className="mt-4 space-y-2">
<div className="h-6 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-5/6 rounded bg-gray-200" />
</div>
</div>
);
}
function PostList() {
const { data: posts, isLoading } = usePosts();
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts?.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}Il punto chiave è che PostCardSkeleton e PostCard devono avere le stesse dimensioni. Se lo skeleton è alto 200px e la card effettiva è alta 280px, ottieni comunque uno spostamento.
I miei risultati CLS:
- Prima: 0.12 (il solo cambio di font era 0.06)
- Dopo: 0.00 — letteralmente zero, su migliaia di page load nei dati CrUX
INP: il nuovo che morde#
L'Interaction to Next Paint ha sostituito il First Input Delay a marzo 2024, ed è una metrica fondamentalmente più difficile da ottimizzare. Il FID misurava solo il ritardo prima che la prima interazione venisse elaborata. L'INP misura ogni interazione durante l'intero ciclo di vita della pagina e riporta la peggiore (al 75esimo percentile).
Questo significa che una pagina può avere un ottimo FID ma un terribile INP se, per esempio, cliccare un menu dropdown 30 secondi dopo il caricamento scatena un reflow da 500ms.
Cosa causa un INP alto#
- Long task sul main thread — Qualsiasi esecuzione JavaScript che dura più di 50ms blocca il main thread. Le interazioni utente che avvengono durante un long task devono aspettare.
- Re-render costosi in React — Un aggiornamento dello stato che causa il re-render di 200 componenti richiede tempo. L'utente clicca qualcosa, React riconcilia, e il paint non avviene per 300ms.
- Layout thrashing — Leggere proprietà di layout (come
offsetHeight) e poi scriverle (come cambiarestyle.height) in un loop forza il browser a ricalcolare il layout in modo sincrono. - DOM grande — Più nodi DOM significa ricalcolo degli stili e layout più lento. Un DOM con 5.000 nodi è notevolmente più lento di uno con 500.
Spezzare i long task con scheduler.yield()#
La tecnica più impattante per l'INP è spezzare i long task in modo che il browser possa elaborare le interazioni utente tra i blocchi:
async function processLargeDataset(items: DataItem[]) {
const results: ProcessedItem[] = [];
for (let i = 0; i < items.length; i++) {
results.push(expensiveTransform(items[i]));
// Ogni 10 elementi, cedi il controllo al browser
// Questo permette alle interazioni utente in sospeso di essere elaborate
if (i % 10 === 0 && "scheduler" in globalThis) {
await scheduler.yield();
}
}
return results;
}scheduler.yield() è disponibile in Chrome 129+ (settembre 2024) ed è il modo raccomandato per cedere il controllo al main thread. Per i browser che non lo supportano, puoi usare un wrapper con setTimeout(0) come fallback:
function yieldToMain(): Promise<void> {
if ("scheduler" in globalThis && "yield" in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}useTransition per aggiornamenti non urgenti#
React 18+ ci dà useTransition, che dice a React che certi aggiornamenti dello stato non sono urgenti e possono essere interrotti da lavoro più importante (come rispondere all'input dell'utente):
import { useState, useTransition } from "react";
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
// Questo aggiornamento è urgente — l'input deve riflettere il tasto immediatamente
setQuery(value);
// Questo aggiornamento NON è urgente — filtrare 10.000 elementi può aspettare
startTransition(() => {
const filtered = items.filter((item) =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(filtered);
});
}
return (
<div>
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Cerca..."
className="w-full rounded border px-4 py-2"
/>
{isPending && (
<p className="mt-2 text-sm text-gray-500">Filtrando...</p>
)}
<ul className="mt-4 space-y-2">
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}Senza startTransition, digitare nell'input di ricerca risulterebbe lento perché React cercherebbe di filtrare 10.000 elementi in modo sincrono prima di aggiornare il DOM. Con startTransition, l'input si aggiorna immediatamente, e il filtraggio avviene in background.
Ho misurato l'INP su una pagina tool che aveva un handler di input complesso. Prima di useTransition: 380ms INP. Dopo: 90ms INP. Un miglioramento del 76% da un cambio di API React.
Debouncing degli handler di input#
Per gli handler che scatenano operazioni costose (chiamate API, calcoli pesanti), applica il debounce:
import { useCallback, useRef } from "react";
function useDebounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): T {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
return useCallback(
((...args: unknown[]) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => fn(...args), delay);
}) as T,
[fn, delay]
);
}
// Utilizzo
function LiveSearch() {
const [results, setResults] = useState<SearchResult[]>([]);
const search = useDebounce(async (query: string) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data.results);
}, 300);
return (
<input
type="text"
onChange={(e) => search(e.target.value)}
placeholder="Cerca..."
/>
);
}300ms è il mio valore di debounce predefinito. È abbastanza breve perché gli utenti non notino il ritardo, abbastanza lungo per evitare di scatenare l'azione ad ogni pressione di tasto.
Web Worker per calcoli pesanti#
Se hai calcoli genuinamente pesanti (parsing di JSON grandi, manipolazione di immagini, calcoli complessi), spostali completamente fuori dal main thread:
// worker.ts
self.addEventListener("message", (event) => {
const { data, operation } = event.data;
switch (operation) {
case "sort": {
// Questo potrebbe richiedere 500ms per dataset grandi
const sorted = data.sort((a: number, b: number) => a - b);
self.postMessage({ result: sorted });
break;
}
case "filter": {
const filtered = data.filter((item: DataItem) =>
complexFilterLogic(item)
);
self.postMessage({ result: filtered });
break;
}
}
});// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
function useWorker() {
const workerRef = useRef<Worker>();
useEffect(() => {
workerRef.current = new Worker(
new URL("../workers/worker.ts", import.meta.url)
);
return () => workerRef.current?.terminate();
}, []);
const process = useCallback(
(operation: string, data: unknown): Promise<unknown> => {
return new Promise((resolve) => {
if (!workerRef.current) return;
workerRef.current.onmessage = (event) => {
resolve(event.data.result);
};
workerRef.current.postMessage({ operation, data });
});
},
[]
);
return { process };
}I Web Worker operano su un thread separato, quindi anche un calcolo di 2 secondi non influenzerà per niente l'INP. Il main thread resta libero di gestire le interazioni utente.
I miei risultati INP:
- Prima: 340ms (la peggior interazione era un tool di test regex con gestione complessa dell'input)
- Dopo useTransition + debouncing: 110ms
- Miglioramento: 68% di riduzione
Le vittorie specifiche di Next.js#
Se sei su Next.js (13+ con App Router), hai accesso ad alcune potenti primitive per le performance che la maggior parte degli sviluppatori non sfrutta appieno.
next/image — ma configurato bene#
next/image è ottimo, ma la configurazione di default lascia performance sul tavolo:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
minimumCacheTTL: 60 * 60 * 24 * 365, // 1 anno
},
};
export default nextConfig;Impostazioni chiave:
formats: ["image/avif", "image/webp"]— AVIF è il 20-50% più piccolo di WebP. L'ordine conta: Next.js prova prima AVIF, poi ripega su WebP, poi sul formato originale.minimumCacheTTL— Il default è 60 secondi. Per un blog, le immagini non cambiano. Cachale per un anno.deviceSizeseimageSizes— I default includono 3840px. A meno che tu non serva immagini 4K, riduci questa lista. Ogni dimensione genera un'immagine cachata separata, e le dimensioni inutilizzate sprecano spazio disco e tempo di build.
E usa sempre la prop sizes per dire al browser a quale dimensione l'immagine verrà renderizzata:
// Immagine hero a larghezza piena
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
sizes="100vw"
priority // Immagine LCP — non fare lazy load!
/>
// Immagine card in una griglia responsiva
<Image
src="/card.jpg"
alt="Card"
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>Senza sizes, il browser potrebbe scaricare un'immagine da 1200px per uno slot da 300px. Sono byte sprecati e tempo sprecato.
La prop priority sull'immagine LCP è fondamentale. Disabilita il lazy loading e aggiunge automaticamente fetchPriority="high". Se il tuo elemento LCP è un next/image, aggiungi semplicemente priority e sei già a buon punto.
next/font — font a zero layout shift#
Ho trattato questo nella sezione CLS, ma merita di essere sottolineato. next/font è l'unica soluzione di caricamento font che ho visto raggiungere consistentemente zero CLS:
import { Inter, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={`${inter.variable} ${jetbrainsMono.variable}`}
>
<body className="font-sans">{children}</body>
</html>
);
}Due font, zero CLS, zero richieste esterne a runtime. I font vengono scaricati al build time e serviti dal tuo stesso dominio.
Streaming con Suspense#
Qui è dove Next.js diventa davvero interessante per le performance. Con l'App Router, puoi streammare parti della pagina al browser man mano che diventano pronte:
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
export default function BlogPage() {
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
{/* Questo si carica velocemente — streammalo subito */}
<h1 className="text-4xl font-bold">Blog</h1>
{/* Questo richiede una query al database — streammalo quando è pronto */}
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
<aside>
{/* La sidebar può caricarsi indipendentemente */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</aside>
</div>
);
}Il browser riceve il guscio (heading, navigazione, layout) immediatamente. La lista dei post e la sidebar si streamano man mano che i loro dati diventano disponibili. L'utente vede un caricamento iniziale veloce, e il contenuto si riempie progressivamente.
Questo è particolarmente potente per l'LCP. Se il tuo elemento LCP è l'heading (non la lista dei post), si renderizza immediatamente indipendentemente da quanto tempo impiega la query al database.
Configurazione del segmento di route#
Next.js ti permette di configurare il caching e la rivalidazione a livello di segmento di route:
// app/blog/page.tsx
// Rivalida questa pagina ogni ora
export const revalidate = 3600;
// app/tools/[slug]/page.tsx
// Queste pagine tool sono completamente statiche — genera al build time
export const dynamic = "force-static";
// app/api/search/route.ts
// Route API — non cachare mai
export const dynamic = "force-dynamic";Su questo sito, i post del blog usano revalidate = 3600 (1 ora). Le pagine tool usano force-static perché il loro contenuto non cambia mai tra i deployment. L'API di ricerca usa force-dynamic perché ogni richiesta è unica.
Il risultato: la maggior parte delle pagine viene servita dalla cache statica, il TTFB è sotto i 50ms per le pagine cachate, e il server a malapena suda.
Strumenti di misurazione: fidati dei dati, non dei tuoi occhi#
La tua percezione delle performance è inaffidabile. La tua macchina di sviluppo ha 32GB di RAM, un SSD NVMe, e una connessione gigabit. I tuoi utenti no.
Lo stack di misurazione che uso#
1. Pannello Performance di Chrome DevTools
Lo strumento più dettagliato disponibile. Registra un page load, guarda il flamechart, identifica i long task, trova le risorse che bloccano il rendering. È qui che passo la maggior parte del mio tempo di debugging.
Cose chiave da cercare:
- Angoli rossi sui task = long task (>50ms)
- Eventi Layout/Paint scatenati da JavaScript
- Grossi blocchi "Evaluate Script" (troppo JavaScript)
- Cascata di rete che mostra risorse scoperte in ritardo
2. Lighthouse
Buono per un controllo rapido, ma non ottimizzare per i punteggi Lighthouse. Lighthouse gira in un ambiente throttled simulato che non corrisponde perfettamente alle condizioni del mondo reale. Ho visto pagine con 98 in Lighthouse e 4s di LCP sul campo.
Usa Lighthouse per una guida direzionale, non come tabellone dei punteggi.
3. PageSpeed Insights
Lo strumento più importante per i siti in produzione perché mostra dati CrUX reali — misurazioni effettive da utenti Chrome reali negli ultimi 28 giorni. I dati di laboratorio ti dicono cosa potrebbe succedere. I dati CrUX ti dicono cosa succede davvero.
4. La libreria web-vitals
Aggiungila al tuo sito in produzione per raccogliere metriche dagli utenti reali:
// components/analytics/WebVitals.tsx
"use client";
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
function sendToAnalytics(metric: Metric) {
// Invia al tuo endpoint analytics
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
// Usa sendBeacon così non blocca lo scaricamento della pagina
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", body);
} else {
fetch("/api/vitals", {
body,
method: "POST",
keepalive: true,
});
}
}
export function WebVitals() {
useEffect(() => {
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}, []);
return null;
}Questo ti dà i tuoi dati simili a CrUX, ma con più dettaglio. Puoi segmentare per pagina, tipo di dispositivo, velocità di connessione, regione geografica — qualsiasi cosa ti serva.
5. Chrome User Experience Report (CrUX)
Il dataset BigQuery di CrUX è gratuito e contiene dati a rotazione su 28 giorni per milioni di origini. Se il tuo sito riceve abbastanza traffico, puoi interrogare i tuoi stessi dati:
SELECT
origin,
p75_lcp,
p75_cls,
p75_inp,
form_factor
FROM
`chrome-ux-report.materialized.metrics_summary`
WHERE
origin = 'https://yoursite.com'
AND yyyymm = 202603La lista nera della cascata#
Gli script di terze parti sono il killer numero uno delle performance sulla maggior parte dei siti web. Ecco cosa ho trovato e cosa ho fatto.
Google Tag Manager (GTM)#
GTM di per sé è ~80KB. Ma GTM carica altri script — analytics, pixel di marketing, strumenti di A/B testing. Ho visto configurazioni GTM che caricano 15 script aggiuntivi per un totale di 2MB.
Il mio approccio: Non usare GTM in produzione. Carica gli script analytics direttamente, differisci tutto, e usa loading="lazy" per gli script che possono aspettare:
// Invece di GTM che carica tutto
// Carica solo ciò che ti serve, quando ti serve
export function AnalyticsScript() {
return (
<script
defer
src="https://analytics.example.com/script.js"
data-website-id="your-id"
/>
);
}Se devi assolutamente usare GTM, caricalo dopo che la pagina è interattiva:
"use client";
import { useEffect } from "react";
export function DeferredGTM({ containerId }: { containerId: string }) {
useEffect(() => {
// Aspetta fino a dopo il caricamento della pagina per iniettare GTM
const timer = setTimeout(() => {
const script = document.createElement("script");
script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
script.async = true;
document.head.appendChild(script);
}, 3000); // 3 secondi di ritardo
return () => clearTimeout(timer);
}, [containerId]);
return null;
}Sì, perderai dati dagli utenti che rimbalzano nei primi 3 secondi. Nella mia esperienza, è un compromesso che vale la pena. Quegli utenti non stavano convertendo comunque.
Widget di chat#
I widget di chat live (Intercom, Drift, Crisp) sono tra i peggiori colpevoli. Solo Intercom carica 400KB+ di JavaScript. Su una pagina dove il 2% degli utenti effettivamente clicca il bottone della chat, sono 400KB di JavaScript per il 98% degli utenti.
La mia soluzione: Carica il widget all'interazione.
"use client";
import { useState } from "react";
export function ChatButton() {
const [loaded, setLoaded] = useState(false);
function loadChat() {
if (loaded) return;
// Carica lo script del widget chat solo quando l'utente clicca
const script = document.createElement("script");
script.src = "https://chat-widget.example.com/widget.js";
script.onload = () => {
// Inizializza il widget dopo che lo script si è caricato
window.ChatWidget?.open();
};
document.head.appendChild(script);
setLoaded(true);
}
return (
<button
onClick={loadChat}
className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
aria-label="Apri chat"
>
{loaded ? "Caricamento..." : "Chatta con noi"}
</button>
);
}JavaScript inutilizzato#
Esegui Coverage in Chrome DevTools (Ctrl+Shift+P > "Show Coverage"). Ti mostra esattamente quanto di ogni script è effettivamente usato nella pagina corrente.
Su un tipico sito Next.js, di solito trovo:
- Librerie di componenti caricate interamente — Importi
Buttonda una libreria UI, ma l'intera libreria viene inclusa nel bundle. Soluzione: usa librerie con tree-shaking o importa da sotto-percorsi (import Button from "lib/Button"invece diimport { Button } from "lib"). - Polyfill per browser moderni — Controlla se stai spedendo polyfill per
Promise,fetch, oArray.prototype.includes. Nel 2026, non ne hai bisogno. - Feature flag morti — Percorsi di codice dietro feature flag che sono stati "on" per sei mesi. Rimuovi il flag e il ramo morto.
Uso il bundle analyzer di Next.js per trovare chunk sovradimensionati:
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
// la tua configurazione
};
export default process.env.ANALYZE === "true"
? withBundleAnalyzer({ enabled: true })(nextConfig)
: nextConfig;ANALYZE=true npm run buildQuesto apre una treemap visuale dei tuoi bundle. Ho trovato una libreria di formattazione date da 120KB che ho sostituito con il nativo Intl.DateTimeFormat. Ho trovato un parser markdown da 90KB importato in una pagina che non usava markdown. Piccole vittorie che si sommano.
CSS che blocca il rendering#
L'ho menzionato nella sezione LCP, ma vale la pena ripeterlo perché è così comune. Ogni <link rel="stylesheet"> nell'<head> blocca il rendering. Se hai cinque fogli di stile, il browser aspetta tutti e cinque prima di dipingere qualsiasi cosa.
Next.js con Tailwind gestisce bene questo — il CSS è inlineato e minimale. Ma se stai importando CSS di terze parti, auditalo:
// MALE — carica l'intero CSS della libreria su ogni pagina
import "some-library/dist/styles.css";
// MEGLIO — import dinamico così si carica solo sulle pagine che ne hanno bisogno
const SomeComponent = dynamic(
() => import("some-library").then((mod) => {
// Il CSS viene importato dentro il componente dinamico
import("some-library/dist/styles.css");
return mod.SomeComponent;
}),
{ ssr: false }
);Una storia di ottimizzazione reale#
Vediamo l'ottimizzazione effettiva della pagina tool di questo sito. È una pagina con 15+ tool interattivi, ognuno con il proprio componente, e alcuni di essi (come il tester regex e il formattatore JSON) sono pesanti in JavaScript.
Il punto di partenza#
Misurazioni iniziali (dati CrUX, mobile, 75esimo percentile):
- LCP: 3.8s — Scarso
- CLS: 0.12 — Da migliorare
- INP: 340ms — Scarso
Punteggio Lighthouse: 62.
L'indagine#
Analisi LCP: L'elemento LCP era l'heading della pagina (<h1>), che dovrebbe renderizzarsi istantaneamente. Ma era ritardato da:
- Un file CSS da 200KB da una libreria di componenti (bloccava il rendering)
- Un font custom caricato via CDN di Google Fonts (FOIT di 800ms su connessioni lente)
- TTFB di 420ms perché la pagina era renderizzata lato server ad ogni richiesta senza caching
Analisi CLS: Tre fonti:
- Scambio di font dal fallback di Google Fonts al font custom: 0.06
- Card dei tool che si caricavano senza riserva di altezza: 0.04
- Un banner cookie iniettato in cima alla pagina, che spingeva tutto verso il basso: 0.02
Analisi INP: Il tool tester regex era il peggior colpevole. Ogni pressione di tasto nell'input regex scatenava:
- Un re-render completo dell'intero componente del tool
- Valutazione della regex contro la stringa di test
- Syntax highlighting del pattern regex
Tempo totale per pressione di tasto: 280-400ms.
Le correzioni#
Settimana 1: LCP e CLS
-
Sostituito il CDN Google Fonts con
next/font. Il font è ora self-hosted, caricato al build time, con fallback a dimensioni regolate. CLS dai font: 0.06 → 0.00 -
Rimosso il CSS della libreria di componenti. Riscritto i 3 componenti che usavo con Tailwind. CSS totale rimosso: 180KB. CSS bloccante il rendering: eliminato
-
Aggiunto
revalidate = 3600alla pagina tool e alle pagine di dettaglio dei tool. Il primo accesso è renderizzato lato server, gli accessi successivi servono dalla cache. TTFB: 420ms → 45ms (cachato) -
Aggiunte dimensioni esplicite a tutti i componenti card dei tool e usato
aspect-ratioper i layout responsivi. CLS dalle card: 0.04 → 0.00 -
Spostato il banner cookie a
position: fixedin basso allo schermo. CLS dal banner: 0.02 → 0.00
Settimana 2: INP
- Wrappato il calcolo dei risultati del tester regex in
startTransition:
function RegexTester() {
const [pattern, setPattern] = useState("");
const [testString, setTestString] = useState("");
const [results, setResults] = useState<RegexResult[]>([]);
const [isPending, startTransition] = useTransition();
function handlePatternChange(value: string) {
setPattern(value); // Urgente: aggiorna l'input
startTransition(() => {
// Non urgente: calcola le corrispondenze
try {
const regex = new RegExp(value, "g");
const matches = [...testString.matchAll(regex)];
setResults(
matches.map((m) => ({
match: m[0],
index: m.index ?? 0,
groups: m.groups,
}))
);
} catch {
setResults([]);
}
});
}
return (
<div>
<input
value={pattern}
onChange={(e) => handlePatternChange(e.target.value)}
className={isPending ? "opacity-70" : ""}
/>
{/* rendering dei risultati */}
</div>
);
}INP sul tester regex: 380ms → 85ms
-
Aggiunto debouncing all'handler di input del formattatore JSON (300ms di ritardo). INP sul formattatore JSON: 260ms → 60ms
-
Spostato il calcolo del generatore di hash in un Web Worker. L'hashing SHA-256 di input grandi ora avviene completamente fuori dal main thread. INP sul generatore di hash: 200ms → 40ms
I risultati#
Dopo due settimane di ottimizzazione (dati CrUX, mobile, 75esimo percentile):
- LCP: 3.8s → 1.8s (53% di miglioramento)
- CLS: 0.12 → 0.00 (100% di miglioramento)
- INP: 340ms → 110ms (68% di miglioramento)
Punteggio Lighthouse: 62 → 97.
Tutte e tre le metriche solidamente nel range "Buono". La pagina sembra istantanea su mobile. E il traffico di ricerca organica è aumentato del 12% nel mese successivo ai miglioramenti (anche se non posso provare la causalità — altri fattori erano in gioco).
La checklist#
Se non prendi nient'altro da questo post, ecco la checklist che seguo su ogni progetto:
LCP#
- Identifica l'elemento LCP con DevTools
- Aggiungi
priority(ofetchPriority="high") all'immagine LCP - Precarica le risorse LCP nell'
<head> - Elimina il CSS che blocca il rendering
- Self-hosta i font con
next/font - Abilita la compressione Brotli/Gzip
- Usa la generazione statica o ISR dove possibile
- Imposta header di cache aggressivi per gli asset statici
CLS#
- Tutte le immagini hanno
widtheheightespliciti - Usi
next/fontcon fallback a dimensioni regolate - Il contenuto dinamico usa
position: fixed/absoluteo spazio riservato - Gli skeleton screen corrispondono alle dimensioni effettive dei componenti
- Nessuna iniezione di contenuto in cima alla pagina dopo il caricamento
INP#
- Nessun long task (>50ms) durante gli handler di interazione
- Aggiornamenti di stato non urgenti wrappati in
startTransition - Handler di input con debounce (300ms)
- Calcoli pesanti scaricati ai Web Worker
- Dimensione DOM sotto i 1.500 nodi dove possibile
Generale#
- Script di terze parti caricati dopo che la pagina è interattiva
- Dimensione del bundle analizzata e tree-shaken
- CSS inutilizzato rimosso
- Immagini servite in formato AVIF/WebP
- Monitoraggio utenti reali in produzione (libreria web-vitals)
Considerazioni finali#
L'ottimizzazione delle performance non è un'attività una tantum. È una disciplina. Ogni nuova feature, ogni nuova dipendenza, ogni nuovo script di terze parti è una potenziale regressione. I siti che restano veloci sono quelli dove qualcuno monitora le metriche continuamente, non quelli dove qualcuno ha fatto uno sprint di ottimizzazione una sola volta.
Imposta il monitoraggio degli utenti reali. Imposta alert quando le metriche regrediscono. Rendi le performance parte del tuo processo di code review. Quando qualcuno aggiunge una libreria da 200KB, chiedi se esiste un'alternativa da 5KB. Quando qualcuno aggiunge un calcolo sincrono in un handler di eventi, chiedi se può essere differito o spostato in un worker.
Le tecniche in questo post non sono teoriche. Sono quello che ho fatto davvero, su questo sito, con numeri reali a supporto. I tuoi risultati varieranno — ogni sito è diverso, ogni pubblico è diverso, ogni infrastruttura è diversa. Ma i principi sono universali: carica meno, carica in modo più intelligente, non bloccare il main thread.
I tuoi utenti non ti manderanno un biglietto di ringraziamento per un sito veloce. Ma resteranno. Torneranno. E Google se ne accorgerà.